From e0997cf8c4d5a0627fe01d804a3aff957ddcf6fb Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 12:09:28 -0500 Subject: [PATCH 1/7] feat: add shared quick-demo foundation --- apps/web/app/board/lib/triage.ts | 54 ++-- apps/web/lib/guide-access.ts | 24 ++ apps/web/package.json | 1 + apps/web/test/board-without-narrator.test.ts | 6 +- apps/web/test/quick-demo-access.test.ts | 4 + apps/web/tsconfig.json | 3 + db/migrations/007_shared_foundation.sql | 31 ++ package-lock.json | 19 +- packages/application/package.json | 25 ++ packages/application/src/access.ts | 20 ++ packages/application/src/board-compiler.ts | 21 ++ packages/application/src/board-reader.ts | 70 +++++ packages/application/src/evidence-reader.ts | 60 ++++ packages/application/src/importer.ts | 66 +++++ packages/application/src/index.ts | 6 + packages/application/test/contracts.test.ts | 22 ++ packages/application/tsconfig.json | 5 + packages/application/vitest.config.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/seed/index.ts | 4 +- packages/core/src/seed/item-bank.ts | 22 +- .../core/src/seed/winsorization-bounds.ts | 15 +- packages/core/src/types.ts | 140 +++++---- packages/core/test/seed-integrity.test.ts | 7 + packages/db/package.json | 20 ++ packages/db/src/acknowledgement.ts | 2 + packages/db/src/boards.ts | 2 + packages/db/src/evidence.ts | 2 + packages/db/src/imports.ts | 2 + packages/db/src/refresh.ts | 2 + packages/db/src/scoped.ts | 1 + packages/db/test/determinism-gate.test.ts | 14 + packages/db/test/scoped-access.test.ts | 1 + .../test/shared-foundation-migration.test.ts | 20 ++ packages/ingest/package.json | 2 - packages/ingest/src/winsorize.ts | 22 +- packages/ingest/test/winsorize.test.ts | 41 +-- .../narrator/test/request-contract.test.ts | 31 +- packages/signal-engine/src/baselines/index.ts | 5 +- packages/signal-engine/src/confidence.ts | 33 ++- packages/signal-engine/src/engine.ts | 198 ++++++++----- packages/signal-engine/src/evidence.ts | 266 +++++++++--------- packages/signal-engine/src/rank/index.ts | 169 +++++------ packages/signal-engine/src/severity.ts | 2 +- packages/signal-engine/src/windows.ts | 73 ++++- .../signal-engine/test/board-window.test.ts | 47 ++-- .../signal-engine/test/determinism.test.ts | 7 +- .../test/half-open-engine.test.ts | 62 ++++ .../test/per-fired-rule-evidence.test.ts | 62 ++++ .../signal-engine/test/rank-tiebreak.test.ts | 13 +- packages/signal-engine/test/ranking.test.ts | 11 +- packages/signal-engine/test/rule-context.ts | 4 + .../test/scenario-guessing.test.ts | 11 +- scripts/check-determinism.ts | 22 ++ .../contracts/application-interfaces.md | 27 +- .../contracts/ingest-adapter.md | 12 +- specs/001-huddle-triage-board/spec.md | 16 +- specs/001-huddle-triage-board/tasks.md | 4 +- tsconfig.json | 1 + 59 files changed, 1301 insertions(+), 534 deletions(-) create mode 100644 apps/web/lib/guide-access.ts create mode 100644 db/migrations/007_shared_foundation.sql create mode 100644 packages/application/package.json create mode 100644 packages/application/src/access.ts create mode 100644 packages/application/src/board-compiler.ts create mode 100644 packages/application/src/board-reader.ts create mode 100644 packages/application/src/evidence-reader.ts create mode 100644 packages/application/src/importer.ts create mode 100644 packages/application/src/index.ts create mode 100644 packages/application/test/contracts.test.ts create mode 100644 packages/application/tsconfig.json create mode 100644 packages/application/vitest.config.ts create mode 100644 packages/db/src/acknowledgement.ts create mode 100644 packages/db/src/boards.ts create mode 100644 packages/db/src/evidence.ts create mode 100644 packages/db/src/imports.ts create mode 100644 packages/db/src/refresh.ts create mode 100644 packages/db/test/shared-foundation-migration.test.ts create mode 100644 packages/signal-engine/test/half-open-engine.test.ts create mode 100644 packages/signal-engine/test/per-fired-rule-evidence.test.ts diff --git a/apps/web/app/board/lib/triage.ts b/apps/web/app/board/lib/triage.ts index 66587b5..584f1cb 100644 --- a/apps/web/app/board/lib/triage.ts +++ b/apps/web/app/board/lib/triage.ts @@ -44,42 +44,44 @@ export type BoardRequestDependencies = { export type AuthenticatedBoardResult = { status: 'authorized'; rows: BoardRow[] } | { status: 'unavailable'; rows: [] }; -async function getProductionDependencies(): Promise { - return { - async getVerifiedAuthUserId() { - const { getVerifiedAuthUserId } = await import('../../../lib/supabase/server'); - return getVerifiedAuthUserId(); - }, - async getGuideScopeForAuthUser(authUserId) { - // The direct Postgres client rejects missing configuration at import time. Check before - // importing it so an incomplete demo deployment refuses access without leaking board state. - if (!process.env.DATABASE_URL) return null; - const { getGuideScopeForAuthUser } = await import('@huddle/db/scoped.js'); - return getGuideScopeForAuthUser(authUserId); - }, - async getTriageBoardForGuide(scope, boardDate) { - const { getTriageBoardForGuide } = await import('@huddle/db/scoped.js'); - return getTriageBoardForGuide(scope, boardDate); - }, - }; -} - /** * Request-time board boundary. No caller may supply a guide id: an authenticated Supabase identity * must first resolve to exactly one server-owned guide and synthetic studio scope. */ +export function chicagoBoardDate(now: Date): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Chicago', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const values = Object.fromEntries( + parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]) + ); + return `${values.year}-${values.month}-${values.day}`; +} + export async function getBoardEntriesForAuthenticatedRequest( dependencies?: BoardRequestDependencies, - boardDate = new Date().toISOString().slice(0, 10) + boardDate?: string ): Promise { - const deps = dependencies ?? (await getProductionDependencies()); - const authUserId = await deps.getVerifiedAuthUserId(); + const resolvedBoardDate = boardDate ?? chicagoBoardDate(new Date()); + if (!dependencies) { + const { resolveGuideAccess } = await import('../../../lib/guide-access'); + const access = await resolveGuideAccess(); + if (!access) return { status: 'unavailable', rows: [] }; + const { getTriageBoardForGuide } = await import('@huddle/db/scoped.js'); + const rows = await getTriageBoardForGuide(access, resolvedBoardDate); + return toBoardResult(rows); + } + const authUserId = await dependencies.getVerifiedAuthUserId(); if (!authUserId) return { status: 'unavailable', rows: [] }; - - const scope = await deps.getGuideScopeForAuthUser(authUserId); + const scope = await dependencies.getGuideScopeForAuthUser(authUserId); if (!scope) return { status: 'unavailable', rows: [] }; + return toBoardResult(await dependencies.getTriageBoardForGuide(scope, resolvedBoardDate)); +} - const rows = await deps.getTriageBoardForGuide(scope, boardDate); +function toBoardResult(rows: PersistedBoardRow[]): AuthenticatedBoardResult { return { status: 'authorized', rows: rows.map((row) => ({ diff --git a/apps/web/lib/guide-access.ts b/apps/web/lib/guide-access.ts new file mode 100644 index 0000000..538183f --- /dev/null +++ b/apps/web/lib/guide-access.ts @@ -0,0 +1,24 @@ +import 'server-only'; + +import type { GuideAccess } from '@huddle/application'; +import { getVerifiedAuthUserId } from './supabase/server'; + +/** + * The sole browser-independent composition boundary for trusted guide scope. + * No route, form, credential, or client may supply a guide/studio/timezone/data scope. + */ +export async function resolveGuideAccess(): Promise { + const authUserId = await getVerifiedAuthUserId(); + if (!authUserId || !process.env.DATABASE_URL) return null; + const { getGuideScopeForAuthUser } = await import('@huddle/db/scoped.js'); + const scope = await getGuideScopeForAuthUser(authUserId); + return scope + ? { + authUserId, + guideId: scope.guideId, + studioId: scope.studioId, + role: 'guide', + syntheticOnly: true, + } + : null; +} diff --git a/apps/web/package.json b/apps/web/package.json index 008246e..013c254 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "@huddle/application": "^0.1.0", "@huddle/core": "^0.1.0", "@huddle/db": "^0.1.0", "@huddle/signal-engine": "^0.1.0", diff --git a/apps/web/test/board-without-narrator.test.ts b/apps/web/test/board-without-narrator.test.ts index ea7a19c..37cad53 100644 --- a/apps/web/test/board-without-narrator.test.ts +++ b/apps/web/test/board-without-narrator.test.ts @@ -8,7 +8,7 @@ describe('Board without narrator', () => { const rows = await getSyntheticFixtureBoardEntriesForTest(); expect(rows.map((row) => row.rank)).toEqual([1, 2]); - expect(rows.map((row) => row.cause)).toEqual(['guessing', 'prerequisite_gap']); + expect(rows.map((row) => row.cause)).toEqual(['prerequisite_gap', 'guessing']); expect(rows.some((row) => row.cause === 'fine')).toBe(false); expect(rows.every((row) => row.diagnosis === null && row.opener === null)).toBe(true); }); @@ -17,10 +17,10 @@ describe('Board without narrator', () => { const rows = await getSyntheticFixtureBoardEntriesForTest(); expect(rows.map((row) => row.studentId)).toEqual([ - '11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', + '11111111-1111-1111-1111-111111111111', ]); - expect(rows.map((row) => row.studentFirstName)).toEqual(['Avery', 'Blake']); + expect(rows.map((row) => row.studentFirstName)).toEqual(['Blake', 'Avery']); }); }); diff --git a/apps/web/test/quick-demo-access.test.ts b/apps/web/test/quick-demo-access.test.ts index 0c69f3e..022088e 100644 --- a/apps/web/test/quick-demo-access.test.ts +++ b/apps/web/test/quick-demo-access.test.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it, vi } from 'vitest'; import { + chicagoBoardDate, getBoardEntriesForAuthenticatedRequest, type BoardRequestDependencies, } from '../app/board/lib/triage'; @@ -22,6 +23,9 @@ function dependencies(overrides: Partial = {}): BoardR } describe('quick-demo authenticated board access', () => { + it('derives the default BoardKey from America/Chicago rather than UTC', () => { + expect(chicagoBoardDate(new Date('2026-03-09T00:30:00.000Z'))).toBe('2026-03-08'); + }); it('fails closed before any scope or board query when Supabase has no verified user', async () => { const deps = dependencies({ getVerifiedAuthUserId: vi.fn(async () => null) }); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9ae6c80..5f3f0d8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -22,6 +22,9 @@ { "path": "../../packages/core" }, + { + "path": "../../packages/application" + }, { "path": "../../packages/db" }, diff --git a/db/migrations/007_shared_foundation.sql b/db/migrations/007_shared_foundation.sql new file mode 100644 index 0000000..2d7f51b --- /dev/null +++ b/db/migrations/007_shared_foundation.sql @@ -0,0 +1,31 @@ +-- Shared foundation reservation. Feature owners extend only after these identities exist. +-- 008_import_receipt_session.sql: import_run / learning_session / receipt lineage +-- 009_board_run_head.sql: board_refresh_request / immutable board_run / board_head / run entries +-- 010_evidence_acknowledgment.sql: acknowledgment grants, nonces, replay ledger (must follow 009) +-- Legacy synthetic derived attempts/boards are rebuilt from canonical fixtures by the target +-- importer/compiler. Retained legacy rows must be explicitly legacy/non-displayable and never heads. + +ALTER TABLE item + ADD COLUMN IF NOT EXISTS item_type text NOT NULL DEFAULT 'multiple_choice' + CHECK (item_type IN ('multiple_choice', 'numeric', 'short_text')), + ADD COLUMN IF NOT EXISTS timing_profile text NOT NULL DEFAULT 'standard_multiple_choice' + CHECK (timing_profile IN ('standard_multiple_choice', 'word_problem')); + +-- Existing date-keyed derived rows lack target receipt/rule/evidence provenance. They remain +-- historical only and cannot be returned as an active board while the target compiler recomputes. +ALTER TABLE triage_entry + ADD COLUMN IF NOT EXISTS legacy_non_displayable boolean NOT NULL DEFAULT false; +UPDATE triage_entry SET legacy_non_displayable = true WHERE legacy_non_displayable = false; + +-- Mastery belongs to the same half-open [start,end) semantics as board compilation. +CREATE OR REPLACE FUNCTION mastery_at(anchor timestamptz) +RETURNS TABLE (student_id uuid, skill_id text, value real, attempt_count bigint, is_known boolean) +LANGUAGE sql STABLE AS $$ + SELECT a.student_id, a.skill_id, + (sum(a.is_correct::int * exp(-ln(2) * extract(epoch FROM (anchor - a.submitted_at)) / 86400.0 / 14.0)) + / nullif(sum(exp(-ln(2) * extract(epoch FROM (anchor - a.submitted_at)) / 86400.0 / 14.0)), 0))::real, + count(*), count(*) >= 5 + FROM attempt a + WHERE a.submitted_at < anchor + GROUP BY a.student_id, a.skill_id; +$$; diff --git a/package-lock.json b/package-lock.json index 3a3a18d..51f0146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "name": "@huddle/web", "version": "0.1.0", "dependencies": { + "@huddle/application": "^0.1.0", "@huddle/core": "^0.1.0", "@huddle/db": "^0.1.0", "@huddle/signal-engine": "^0.1.0", @@ -663,6 +664,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@huddle/application": { + "resolved": "packages/application", + "link": true + }, "node_modules/@huddle/core": { "resolved": "packages/core", "link": true @@ -4221,6 +4226,18 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/application": { + "name": "@huddle/application", + "version": "0.1.0", + "dependencies": { + "@huddle/core": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } + }, "packages/core": { "name": "@huddle/core", "version": "0.1.0", @@ -4268,12 +4285,10 @@ "version": "0.1.0", "dependencies": { "@huddle/core": "^0.1.0", - "pg": "^8.12.0", "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^20.14.0", - "@types/pg": "^8.11.0", "typescript": "^5.5.0", "vitest": "^2.0.0" } diff --git a/packages/application/package.json b/packages/application/package.json new file mode 100644 index 0000000..e8c5994 --- /dev/null +++ b/packages/application/package.json @@ -0,0 +1,25 @@ +{ + "name": "@huddle/application", + "version": "0.1.0", + "type": "module", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } + }, + "scripts": { + "build": "tsc -b", + "test": "vitest run" + }, + "dependencies": { + "@huddle/core": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/application/src/access.ts b/packages/application/src/access.ts new file mode 100644 index 0000000..f2f7ed7 --- /dev/null +++ b/packages/application/src/access.ts @@ -0,0 +1,20 @@ +export interface GuideAccess { + authUserId: string; + guideId: string; + studioId: string; + role: 'guide'; + syntheticOnly: true; +} + +export interface SchedulerAccess { + workerId: string; + capability: 'board-refresh'; + syntheticOnly: true; +} + +export interface BoardKey { + boardDate: string; +} + +/** Server composition must verify Auth and resolve exactly one synthetic guide scope. */ +export type ResolveGuideAccess = () => Promise; diff --git a/packages/application/src/board-compiler.ts b/packages/application/src/board-compiler.ts new file mode 100644 index 0000000..5a62f47 --- /dev/null +++ b/packages/application/src/board-compiler.ts @@ -0,0 +1,21 @@ +import type { GuideAccess, SchedulerAccess } from './access.js'; +import type { RefreshFailureCode, RefreshView } from './board-reader.js'; + +export type RefreshRequestInput = + | { trigger: 'manual'; access: GuideAccess; boardDate: string } + | { + trigger: 'nightly'; + scheduler: SchedulerAccess; + applicationScopeId: string; + boardDate: string; + }; +export interface BoardCompiler { + request(input: RefreshRequestInput): Promise; + compile( + scheduler: SchedulerAccess, + refreshRequestId: string + ): Promise< + | { kind: 'succeeded'; boardRunId: string; replacedBoardRunId: string | null } + | { kind: 'failed'; preservedBoardRunId: string | null; failureCode: RefreshFailureCode } + >; +} diff --git a/packages/application/src/board-reader.ts b/packages/application/src/board-reader.ts new file mode 100644 index 0000000..fafa8fe --- /dev/null +++ b/packages/application/src/board-reader.ts @@ -0,0 +1,70 @@ +import type { EvidenceScope, RootCause } from '@huddle/core'; +import type { BoardKey, GuideAccess } from './access.js'; + +export type RefreshFailureCode = + | 'input-unavailable' + | 'compile-failed' + | 'persistence-failed' + | 'deadline-missed' + | 'worker-timeout'; +export type RefreshView = + | { state: 'idle' } + | { state: 'queued' | 'running'; requestId: string; requestedAt: string } + | { state: 'succeeded'; requestId: string; completedAt: string; boardRunId: string } + | { + state: 'failed'; + requestId: string; + completedAt: string; + failureCode: RefreshFailureCode; + preservedBoardRunId: string | null; + }; + +export interface NarrationView { + mode: 'generated' | 'deterministic-fallback'; + status: 'complete' | 'degraded'; + degradedReason: + | null + | 'pending' + | 'model-unavailable' + | 'timeout' + | 'provider-error' + | 'selection-invalid' + | 'grounding-rejected' + | 'stale-result'; + catalogVersion: string; + renderVersion: string; +} +export interface BoardEntryView { + triageEntryId: string; + findingFingerprint: string; + student: { id: string; firstName: string }; + rank: number; + cause: RootCause; + scope: EvidenceScope; + severity: number; + finalConfidence: number; + diagnosis: string; + opener: string; + narration: NarrationView; + acknowledgedAt: string | null; + additionalCauseCount: number; +} +export type BoardView = + | { kind: 'not-built'; refresh: RefreshView } + | { + kind: 'ready' | 'successful-empty' | 'stale'; + boardRunId: string; + requestedBoardDate: string; + boardDate: string; + asOf: string; + timezone: 'America/Chicago'; + completedAt: string; + inputReceiptSetFingerprint: string; + entries: BoardEntryView[]; + refresh: RefreshView; + narration: { status: 'complete' | 'degraded'; degradedCount: number }; + }; + +export interface BoardReader { + readCurrent(access: GuideAccess, key: BoardKey): Promise; +} diff --git a/packages/application/src/evidence-reader.ts b/packages/application/src/evidence-reader.ts new file mode 100644 index 0000000..01cd249 --- /dev/null +++ b/packages/application/src/evidence-reader.ts @@ -0,0 +1,60 @@ +import type { EvidenceBundle } from '@huddle/core'; +import type { GuideAccess } from './access.js'; +import type { BoardEntryView } from './board-reader.js'; + +export interface AcknowledgmentView { + findingFingerprint: string; + acknowledgedAt: string; +} +export interface AdditionalEvidenceView { + signalId: string; + cause: EvidenceBundle['finding']['dominantCause']; + scope: EvidenceBundle['scope']; + summary: EvidenceBundle['finding']; + computed: EvidenceBundle['computed']; + derived: EvidenceBundle['derived']; + prerequisiteCheck: EvidenceBundle['prerequisiteCheck']; + conflicts: EvidenceBundle['conflicts']; + attempts: EvidenceBundle['attempts']; + sessions: EvidenceBundle['sessions']; +} +export interface EvidenceView { + kind: 'evidence'; + boardRunId: string; + entry: BoardEntryView; + summary: { + ruleId: string; + ruleVersion: string; + confidenceBreakdown: EvidenceBundle['finding']['confidenceBreakdown']; + }; + comparison: { + computed: EvidenceBundle['computed']; + derived: EvidenceBundle['derived']; + prerequisiteCheck: EvidenceBundle['prerequisiteCheck']; + conflicts: EvidenceBundle['conflicts']; + additionalCauses: EvidenceBundle['additionalCauses']; + additionalEvidence: AdditionalEvidenceView[]; + }; + exact: { attempts: EvidenceBundle['attempts']; sessions: EvidenceBundle['sessions'] }; +} +export interface AuthorizedEvidenceOpen { + kind: 'authorized-evidence-open'; + evidence: EvidenceView; + openingId: string; + openingRenewalToken: string; + acknowledgmentGrant: { token: string; expiresAt: string }; +} +export interface EvidenceReader { + readEntry( + access: GuideAccess, + input: { boardRunId: string; triageEntryId: string } + ): Promise; + openEntry( + access: GuideAccess, + input: { boardRunId: string; triageEntryId: string } + ): Promise; + acknowledgeVisibleOpen( + access: GuideAccess, + input: { acknowledgmentGrant: string; openingRenewalToken: string } + ): Promise; +} diff --git a/packages/application/src/importer.ts b/packages/application/src/importer.ts new file mode 100644 index 0000000..d932af6 --- /dev/null +++ b/packages/application/src/importer.ts @@ -0,0 +1,66 @@ +import type { GuideAccess } from './access.js'; + +export interface SyntheticImportFile { + name: string; + mediaType: 'text/csv'; + sizeBytes: number; + content: Uint8Array; + idempotencyKey: string; +} +export type ImportConflictCode = + | 'divergent-event-identity' + | 'divergent-session-aggregate' + | 'unknown-synthetic-student' + | 'unknown-seeded-item' + | 'item-skill-mismatch' + | 'invalid-answer-key' + | 'non-synthetic-dataset'; +export interface ImportIssueView { + rowNumber: number; + sourceEventId: string | null; + outcome: 'duplicate' | 'unmapped' | 'rejected'; + code: string | ImportConflictCode; + field: string | null; + safeDetail: string; +} +export interface ImportPreview { + importId: string; + fileName: string; + payloadDigest: string; + status: 'validated'; + counts: { + received: number; + accepted: number; + duplicate: number; + unmapped: number; + rejected: number; + }; + issues: ImportIssueView[]; +} +export interface ImportReceipt extends Omit { + status: 'succeeded' | 'succeeded-with-rejections'; + committedAt: string; +} +/** Shared fail-closed semantics for future implementations; rejected raw payloads are never retained. */ +export interface ImportConflictPolicy { + canonicalIdenticalDuplicates: 'collapse'; + divergentSameIdentity: 'reject-identity-group-atomically'; + divergentSessionAggregate: 'reject-session-group-atomically'; + references: 'fixed-seeded-synthetic-only'; + rejectedRawRetention: 'none'; +} +export const SYNTHETIC_IMPORT_CONFLICT_POLICY: ImportConflictPolicy = { + canonicalIdenticalDuplicates: 'collapse', + divergentSameIdentity: 'reject-identity-group-atomically', + divergentSessionAggregate: 'reject-session-group-atomically', + references: 'fixed-seeded-synthetic-only', + rejectedRawRetention: 'none', +}; +export interface Importer { + validate(access: GuideAccess, file: SyntheticImportFile): Promise; + commit( + access: GuideAccess, + validatedImportId: string, + file: SyntheticImportFile + ): Promise; +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts new file mode 100644 index 0000000..2b39338 --- /dev/null +++ b/packages/application/src/index.ts @@ -0,0 +1,6 @@ +export type * from './access.js'; +export type * from './board-reader.js'; +export type * from './evidence-reader.js'; +export type * from './board-compiler.js'; +export type * from './importer.js'; +export { SYNTHETIC_IMPORT_CONFLICT_POLICY } from './importer.js'; diff --git a/packages/application/test/contracts.test.ts b/packages/application/test/contracts.test.ts new file mode 100644 index 0000000..86a081f --- /dev/null +++ b/packages/application/test/contracts.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { + SYNTHETIC_IMPORT_CONFLICT_POLICY, + type BoardCompiler, + type BoardReader, + type EvidenceReader, + type Importer, +} from '../src/index.js'; + +describe('application shared contracts', () => { + it('keeps all four deep interfaces framework and persistence independent', () => { + const interfaces: [BoardReader?, EvidenceReader?, BoardCompiler?, Importer?] = []; + expect(interfaces).toHaveLength(0); + expect(SYNTHETIC_IMPORT_CONFLICT_POLICY).toEqual({ + canonicalIdenticalDuplicates: 'collapse', + divergentSameIdentity: 'reject-identity-group-atomically', + divergentSessionAggregate: 'reject-session-group-atomically', + references: 'fixed-seeded-synthetic-only', + rejectedRawRetention: 'none', + }); + }); +}); diff --git a/packages/application/tsconfig.json b/packages/application/tsconfig.json new file mode 100644 index 0000000..004204e --- /dev/null +++ b/packages/application/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": ".", "outDir": "./dist", "composite": true }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/application/vitest.config.ts b/packages/application/vitest.config.ts new file mode 100644 index 0000000..f1c5465 --- /dev/null +++ b/packages/application/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { include: ['test/**/*.test.ts'] } }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f427d18..172092c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export { config } from './config.js'; export type * from './types.js'; +export { timingProfileBoundsMs, timingQualityValues, rootCauseValues } from './types.js'; export * from './normalize.js'; export { loadHandAuthoredFixtures, diff --git a/packages/core/src/seed/index.ts b/packages/core/src/seed/index.ts index a79e06d..342bf93 100644 --- a/packages/core/src/seed/index.ts +++ b/packages/core/src/seed/index.ts @@ -1,7 +1,7 @@ export { skills, skillPrereqs } from './teks-grade4-math.js'; -export { items } from './item-bank.js'; +export { items, timingProfileForSeededItem } from './item-bank.js'; export { MISCONCEPTIONS, DISTRACTOR_KEYS_BY_SKILL } from './misconceptions.js'; -export { WINSORIZATION_BOUNDS } from './winsorization-bounds.js'; +export { WINSORIZATION_BOUNDS, winsorizationBound } from './winsorization-bounds.js'; // Re-export the domain types surfaced by the seed data so external packages can // import them from '@huddle/core/seed' alongside the data fixtures. diff --git a/packages/core/src/seed/item-bank.ts b/packages/core/src/seed/item-bank.ts index d7ae3f3..bf54242 100644 --- a/packages/core/src/seed/item-bank.ts +++ b/packages/core/src/seed/item-bank.ts @@ -1,4 +1,4 @@ -import type { Item, ItemChoice } from '@huddle/core'; +import type { Item, ItemChoice, TimingProfile } from '@huddle/core'; const choice = (key: string, isCorrect: boolean, misconceptionId?: string): ItemChoice => ({ key, @@ -6,7 +6,7 @@ const choice = (key: string, isCorrect: boolean, misconceptionId?: string): Item misconceptionId, }); -export const items: Item[] = [ +const rawItems: Array> = [ { id: 'mcq:TEKS.4.2A-01', skillId: 'TEKS.4.2A', @@ -305,3 +305,21 @@ export const items: Item[] = [ ], }, ]; + +/** All current seeded questions present answer choices; word problems differ only in timing policy. */ +const seededTimingProfiles: Record = { + 'wp:TEKS.4.4H-01': 'word_problem', + 'wp:TEKS.4.5A-01': 'word_problem', + 'wp:TEKS.4.8C-01': 'word_problem', + 'wp:TEKS.4.9B-01': 'word_problem', +}; + +export const items: Item[] = rawItems.map((item) => ({ + ...item, + itemType: 'multiple_choice', + timingProfile: seededTimingProfiles[item.id] ?? 'standard_multiple_choice', +})); + +export function timingProfileForSeededItem(itemId: string): Item['timingProfile'] | null { + return items.find((item) => item.id === itemId)?.timingProfile ?? null; +} diff --git a/packages/core/src/seed/winsorization-bounds.ts b/packages/core/src/seed/winsorization-bounds.ts index d7ade10..6650694 100644 --- a/packages/core/src/seed/winsorization-bounds.ts +++ b/packages/core/src/seed/winsorization-bounds.ts @@ -1,7 +1,8 @@ -export const WINSORIZATION_BOUNDS: Record = { - // 99th-percentile bounds in milliseconds by item-type prefix. - // Prefixes are taken from the item id before the colon (e.g. 'mcq:TEKS.4.2A-01'). - default: 300_000, // 5 minutes: fallback for any un-prefixed item - mcq: 180_000, // 3 minutes: standard multiple-choice items - wp: 300_000, // 5 minutes: word problems that require reading and planning -}; +import { timingProfileBoundsMs, type TimingProfile } from '@huddle/core'; + +/** Frozen synthetic-demo timing policy. The profile, not an identifier prefix, selects a bound. */ +export const WINSORIZATION_BOUNDS: Readonly> = timingProfileBoundsMs; + +export function winsorizationBound(profile: TimingProfile): number { + return WINSORIZATION_BOUNDS[profile]; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3337868..99b4c56 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,7 +2,6 @@ // These mirror the SQL schema and the contracts in specs/001-huddle-triage-board/contracts/. export type TimingQuality = 'engaged' | 'wallclock' | 'session_only' | 'none'; - export const timingQualityValues: TimingQuality[] = [ 'engaged', 'wallclock', @@ -10,6 +9,14 @@ export const timingQualityValues: TimingQuality[] = [ 'none', ]; +/** Response semantics are intentionally independent of timing/winsorization policy. */ +export type ItemType = 'multiple_choice' | 'numeric' | 'short_text'; +export type TimingProfile = 'standard_multiple_choice' | 'word_problem'; +export const timingProfileBoundsMs: Record = { + standard_multiple_choice: 180_000, + word_problem: 300_000, +}; + export type RootCause = | 'guessing' | 'prerequisite_gap' @@ -19,7 +26,6 @@ export type RootCause = | 'decay' | 'disengagement' | 'fine'; - export const rootCauseValues: RootCause[] = [ 'guessing', 'prerequisite_gap', @@ -30,47 +36,44 @@ export const rootCauseValues: RootCause[] = [ 'disengagement', 'fine', ]; - export type EvidenceFamily = 'timing' | 'answer-choice' | 'engagement' | 'mastery'; +export type EvidenceScope = + { kind: 'skill'; skill: { code: string; name: string } } | { kind: 'cross-skill' }; export interface Guide { id: string; displayName: string; } - export interface Student { id: string; guideId: string; firstName: string; grade: number; } - export interface Skill { - id: string; // TEKS code, e.g. 'TEKS.4.3A' + id: string; name: string; grade: number; strand: string; } - export interface SkillPrereq { skillId: string; prereqId: string; strength: number; } - export interface ItemChoice { key: string; isCorrect: boolean; misconceptionId?: string; } - export interface Item { id: string; skillId: string; difficulty: number; choices: ItemChoice[]; + itemType: ItemType; + timingProfile: TimingProfile; } - export interface AbsenceSpan { id: number; studentId: string; @@ -78,6 +81,7 @@ export interface AbsenceSpan { endDate: Date; source: string; } +export type AnswerChoice = { key: string; label?: string; misconceptionId?: string }; export interface NormalizedAttempt { sourceEventId: string; @@ -90,7 +94,6 @@ export interface NormalizedAttempt { submittedAt: Date; elapsedMs: number | null; engagedMs: number | null; - /** Session-level total retained when the source cannot provide per-attempt timing. */ sessionTotalMs: number | null; timingQuality: TimingQuality; timingWasWinsorized: boolean; @@ -98,9 +101,8 @@ export interface NormalizedAttempt { answerGiven: AnswerChoice | null; hintsUsed: number; } - export interface Attempt { - id: number | null; // bigserial, null before persistence + id: number | null; studentId: string; skillId: string; itemId: string; @@ -110,7 +112,6 @@ export interface Attempt { submittedAt: Date; elapsedMs: number | null; engagedMs: number | null; - /** Session-level total retained when the source cannot provide per-attempt timing. */ sessionTotalMs: number | null; timingQuality: TimingQuality; timingWasWinsorized: boolean; @@ -122,38 +123,51 @@ export interface Attempt { ingestedAt: Date; } -export type AnswerChoice = { - key: string; - label?: string; - misconceptionId?: string; -}; - +export interface ConfidenceBreakdown { + timingMultiplier: number; + winsorizationMultiplier: number; + conflictMultiplier: number; +} +/** Stable identity for one fired rule; the numeric id is a persistence surrogate only. */ export interface Signal { id: number | null; + signalIdentity?: string; studentId: string; skillId: string | null; kind: RootCause; severity: number; + /** Compatibility alias for finalConfidence. */ confidence: number; + rawConfidence?: number; + finalConfidence?: number; + confidenceBreakdown?: ConfidenceBreakdown; + scope?: EvidenceScope; + behaviorFingerprint?: string; + evidenceFingerprint?: string; evidence: unknown; ruleVersion: string; windowStart: Date; windowEnd: Date; computedAt: Date; } - export interface TriageEntry { id: number | null; boardDate: Date; studentId: string; dominantSignalId: number; rank: number; - additionalCauses: { cause: RootCause; severity: number }[]; + additionalCauses: Array<{ + signalId?: number; + cause: RootCause; + severity: number; + finalConfidence?: number; + ruleId?: string; + scope?: EvidenceScope; + }>; diagnosis: string | null; opener: string | null; generatedAt: Date | null; } - export interface MasterySnapshot { boardDate: Date; studentId: string; @@ -162,54 +176,76 @@ export interface MasterySnapshot { attemptCount: number; isKnown: boolean; } - export interface PersonalBaseline { studentId: string; - correctPaceMs: number | null; // median elapsed for correct answers + correctPaceMs: number | null; sessionMeanPaceMs: number | null; - volume: number; // attempts in the trailing baseline window - volumePerDay: number; // normalized for a board window of any length + volume: number; + volumePerDay: number; distractorProfile: Record; } -// Evidence bundle: sole input to the narrator and the grounding validator. +export type AbstentionReason = + | { kind: 'missing-input'; input: string } + | { kind: 'insufficient-history'; attemptsSeen: number; required: number } + | { kind: 'untrustworthy-timing'; winsorizedOut: number } + | { kind: 'absence-explains'; span: AbsenceSpan }; +export interface PermittedLanguageOption { + id: string; + slotBindings: Record; +} export interface EvidenceBundle { bundleVersion: string; + behaviorFingerprint: string; student: { id: string; firstName: string }; - skill: { code: string; name: string }; + scope: EvidenceScope; finding: { dominantCause: RootCause; severity: number; - confidence: number; + rawConfidence: number; + finalConfidence: number; + confidenceBreakdown: ConfidenceBreakdown; ruleId: string; ruleVersion: string; }; - window: { start: string; end: string; days: number }; + window: { + timezone: 'America/Chicago'; + start: string; + end: string; + asOf: string; + localDays: number; + }; attempts: Array<{ attemptId: number; ordinal: number; - date: string; + skill: { code: string; name: string }; + itemType: ItemType; + submittedAt: string; isCorrect: boolean; - durationMs: number | null; + elapsedMs: number | null; + engagedMs: number | null; timingQuality: TimingQuality; - timingWasWinsorized: boolean; chosenLabel: string | null; misconception: string | null; hintsUsed: number; }>; + sessions: Array<{ + sessionId: number; + startedAt: string; + endedAt: string; + totalElapsedMs: number | null; + vendorAttemptCount: number | null; + timingQuality: 'session_only' | 'none'; + }>; computed: { attemptCount: number; wrongCount: number; medianWrongDurationMs: number | null; personalCorrectBaselineMs: number | null; + personalSessionMeanBaselineMs: number | null; distractorConcentration: number | null; winsorizedOutCount: number; }; - ruleEvidence: { - attemptIds: number[]; - summary: string; - values: Record; - }; derived: { wrongOfLastN: { wrong: number; of: number } | null; speedRatio: number | null; @@ -223,23 +259,23 @@ export interface EvidenceBundle { isKnown: boolean; verdict: 'adequate' | 'weak' | 'unknown'; } | null; - conflicts: Array<{ - family: EvidenceFamily; - suggestedCause: RootCause; - note: string; + conflicts: Array<{ family: EvidenceFamily; suggestedCause: RootCause; ruleId: string }>; + additionalCauses: Array<{ + signalId: number; + cause: RootCause; + severity: number; + finalConfidence: number; + ruleId: string; + scope: EvidenceScope; }>; - additionalCauses: Array<{ cause: RootCause; severity: number }>; abstentions: Array<{ ruleId: string; reason: AbstentionReason }>; + languageOptions: { + catalogVersion: string; + propositions: PermittedLanguageOption[]; + openers: PermittedLanguageOption[]; + }; } - export interface NarrationResult { diagnosis: string; opener: string; } - -// Re-use the signal-engine AbstentionReason type shape in the bundle. -export type AbstentionReason = - | { kind: 'missing-input'; input: string } - | { kind: 'insufficient-history'; attemptsSeen: number; required: number } - | { kind: 'untrustworthy-timing'; winsorizedOut: number } - | { kind: 'absence-explains'; span: AbsenceSpan }; diff --git a/packages/core/test/seed-integrity.test.ts b/packages/core/test/seed-integrity.test.ts index 6ad0043..6449a2e 100644 --- a/packages/core/test/seed-integrity.test.ts +++ b/packages/core/test/seed-integrity.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect } from 'vitest'; import { skills, skillPrereqs, items } from '../src/seed/index.js'; describe('seed integrity', () => { + it('keeps response semantics separate from fingerprinted timing policy', () => { + const wordProblem = items.find((item) => item.id === 'wp:TEKS.4.4H-01'); + expect(wordProblem).toMatchObject({ + itemType: 'multiple_choice', + timingProfile: 'word_problem', + }); + }); it('has at least one item for every skill, each with a misconception-mapped distractor', () => { const skillIds = new Set(skills.map((s) => s.id)); for (const skillId of skillIds) { diff --git a/packages/db/package.json b/packages/db/package.json index 9f62f06..c09bf50 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -19,6 +19,26 @@ "import": "./dist/src/scoped.js", "default": "./dist/src/scoped.js", "types": "./dist/src/scoped.d.ts" + }, + "./imports.js": { + "import": "./dist/src/imports.js", + "types": "./dist/src/imports.d.ts" + }, + "./boards.js": { + "import": "./dist/src/boards.js", + "types": "./dist/src/boards.d.ts" + }, + "./refresh.js": { + "import": "./dist/src/refresh.js", + "types": "./dist/src/refresh.d.ts" + }, + "./evidence.js": { + "import": "./dist/src/evidence.js", + "types": "./dist/src/evidence.d.ts" + }, + "./acknowledgement.js": { + "import": "./dist/src/acknowledgement.js", + "types": "./dist/src/acknowledgement.d.ts" } }, "scripts": { diff --git a/packages/db/src/acknowledgement.ts b/packages/db/src/acknowledgement.ts new file mode 100644 index 0000000..a5817c1 --- /dev/null +++ b/packages/db/src/acknowledgement.ts @@ -0,0 +1,2 @@ +/** Reserved DB seam: acknowledgment grants/nonces follow immutable board-run identities. */ +export type AcknowledgementPersistenceModule = 'acknowledgement'; diff --git a/packages/db/src/boards.ts b/packages/db/src/boards.ts new file mode 100644 index 0000000..20d8a3c --- /dev/null +++ b/packages/db/src/boards.ts @@ -0,0 +1,2 @@ +/** Reserved DB seam: immutable board runs, entries, and board-head publication live here. */ +export type BoardPersistenceModule = 'boards'; diff --git a/packages/db/src/evidence.ts b/packages/db/src/evidence.ts new file mode 100644 index 0000000..44a410b --- /dev/null +++ b/packages/db/src/evidence.ts @@ -0,0 +1,2 @@ +/** Reserved DB seam: run-scoped evidence bundle retrieval lives here. */ +export type EvidencePersistenceModule = 'evidence'; diff --git a/packages/db/src/imports.ts b/packages/db/src/imports.ts new file mode 100644 index 0000000..70c3f1d --- /dev/null +++ b/packages/db/src/imports.ts @@ -0,0 +1,2 @@ +/** Reserved DB seam: the Operations stream owns import receipts, sessions, and persistence ports. */ +export type ImportPersistenceModule = 'imports'; diff --git a/packages/db/src/refresh.ts b/packages/db/src/refresh.ts new file mode 100644 index 0000000..4f8f023 --- /dev/null +++ b/packages/db/src/refresh.ts @@ -0,0 +1,2 @@ +/** Reserved DB seam: refresh request/claim/lease state lives here. */ +export type RefreshPersistenceModule = 'refresh'; diff --git a/packages/db/src/scoped.ts b/packages/db/src/scoped.ts index 9d10b1f..17a7d9a 100644 --- a/packages/db/src/scoped.ts +++ b/packages/db/src/scoped.ts @@ -120,6 +120,7 @@ export async function getTriageBoardForGuide( AND g.is_synthetic IS TRUE AND s.is_synthetic IS TRUE AND te.board_date = $3::date + AND te.legacy_non_displayable IS FALSE AND sig.kind <> 'fine' ORDER BY te.rank ASC, s.id ASC `, diff --git a/packages/db/test/determinism-gate.test.ts b/packages/db/test/determinism-gate.test.ts index b514ed5..95f29a0 100644 --- a/packages/db/test/determinism-gate.test.ts +++ b/packages/db/test/determinism-gate.test.ts @@ -106,6 +106,20 @@ describe('determinism dependency gate', () => { } }); + it('rejects direct and transitive PostgreSQL-driver reachability outside packages/db', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'huddle-dependency-gate-')); + try { + await writeManifest(fixtureRoot, '@huddle/ingest', { bridge: '^1.0.0' }); + await writeManifest(fixtureRoot, 'bridge', { pg: '^8.0.0' }, 'installed'); + await writeNestedInstalledManifest(fixtureRoot, ['bridge', 'node_modules', 'pg'], 'pg', {}); + expect(await gateOutput(fixtureRoot)).toContain( + '@huddle/ingest reaches forbidden dependency pg' + ); + } finally { + await rm(fixtureRoot, { recursive: true, force: true }); + } + }); + it('rejects a prohibited installed runtime peer dependency', async () => { const fixtureRoot = await mkdtemp(join(tmpdir(), 'huddle-dependency-gate-')); try { diff --git a/packages/db/test/scoped-access.test.ts b/packages/db/test/scoped-access.test.ts index 2095ce9..66d6784 100644 --- a/packages/db/test/scoped-access.test.ts +++ b/packages/db/test/scoped-access.test.ts @@ -75,6 +75,7 @@ describe('guide-scoped student access', () => { expect(calls[0]?.text).toMatch(/g\.is_synthetic IS TRUE/); expect(calls[0]?.text).toMatch(/s\.is_synthetic IS TRUE/); expect(calls[0]?.text).toMatch(/te\.additional_causes AS "additionalCauses"/); + expect(calls[0]?.text).toMatch(/te\.legacy_non_displayable IS FALSE/); expect(calls[0]?.values).toEqual(['guide-a', 'synthetic-huddle-demo', '2026-07-28']); }); }); diff --git a/packages/db/test/shared-foundation-migration.test.ts b/packages/db/test/shared-foundation-migration.test.ts new file mode 100644 index 0000000..5f1d49c --- /dev/null +++ b/packages/db/test/shared-foundation-migration.test.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const migration = fileURLToPath( + new URL('../../../db/migrations/007_shared_foundation.sql', import.meta.url) +); + +describe('shared foundation migration reservation', () => { + it('places import/run/head identities before acknowledgments and keeps legacy derived rows non-displayable', async () => { + const sql = await readFile(migration, 'utf8'); + expect(sql).toMatch( + /008_import_receipt_session[\s\S]*009_board_run_head[\s\S]*010_evidence_acknowledgment/ + ); + expect(sql).toContain('legacy/non-displayable'); + expect(sql).toContain('legacy_non_displayable boolean NOT NULL DEFAULT false'); + expect(sql).toContain('UPDATE triage_entry SET legacy_non_displayable = true'); + expect(sql).toContain('WHERE a.submitted_at < anchor'); + }); +}); diff --git a/packages/ingest/package.json b/packages/ingest/package.json index 8d4f0e2..f2ffea5 100644 --- a/packages/ingest/package.json +++ b/packages/ingest/package.json @@ -16,12 +16,10 @@ }, "dependencies": { "@huddle/core": "^0.1.0", - "pg": "^8.12.0", "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^20.14.0", - "@types/pg": "^8.11.0", "typescript": "^5.5.0", "vitest": "^2.0.0" } diff --git a/packages/ingest/src/winsorize.ts b/packages/ingest/src/winsorize.ts index 5d63c36..59e09b9 100644 --- a/packages/ingest/src/winsorize.ts +++ b/packages/ingest/src/winsorize.ts @@ -1,5 +1,5 @@ -import type { NormalizedAttempt, TimingQuality } from '@huddle/core'; -import { WINSORIZATION_BOUNDS } from '@huddle/core/seed'; +import type { NormalizedAttempt, TimingProfile, TimingQuality } from '@huddle/core'; +import { winsorizationBound } from '@huddle/core/seed'; export interface WinsorizedAttempt extends NormalizedAttempt { elapsedMs: number | null; @@ -8,20 +8,20 @@ export interface WinsorizedAttempt extends NormalizedAttempt { timingWasWinsorized: boolean; } -function boundFor(itemType: string): number { - return WINSORIZATION_BOUNDS[itemType] ?? WINSORIZATION_BOUNDS.default; -} - -/** Apply the frozen item-type plausibility bound once at the ingest boundary. */ -export function winsorize(attempt: NormalizedAttempt): WinsorizedAttempt { - const itemType = attempt.itemId.split(':')[0] ?? 'default'; - const bound = boundFor(itemType); +/** + * Apply the frozen profile bound once at the ingest boundary. The caller must supply + * the resolved seeded item's timing profile; IDs do not carry policy semantics. + */ +export function winsorize( + attempt: NormalizedAttempt, + timingProfile: TimingProfile = 'standard_multiple_choice' +): WinsorizedAttempt { + const bound = winsorizationBound(timingProfile); const elapsedMs = attempt.elapsedMs != null && attempt.elapsedMs > bound ? null : attempt.elapsedMs; const engagedMs = attempt.engagedMs != null && attempt.engagedMs > bound ? null : attempt.engagedMs; const removedDuration = elapsedMs !== attempt.elapsedMs || engagedMs !== attempt.engagedMs; - return { ...attempt, elapsedMs, diff --git a/packages/ingest/test/winsorize.test.ts b/packages/ingest/test/winsorize.test.ts index 5d52791..2d45f0a 100644 --- a/packages/ingest/test/winsorize.test.ts +++ b/packages/ingest/test/winsorize.test.ts @@ -24,42 +24,31 @@ function attempt(overrides: Partial = {}): NormalizedAttempt ...overrides, }; } - describe('winsorize', () => { - it('uses the default bound for an unknown item prefix', () => { + it('uses the resolved standard multiple-choice profile rather than an item identifier prefix', () => { + const bound = WINSORIZATION_BOUNDS.standard_multiple_choice; const result = winsorize( - attempt({ itemId: 'frq:TEKS.4.2A-01', elapsedMs: WINSORIZATION_BOUNDS.default + 1 }) + attempt({ itemId: 'arbitrary:identifier', elapsedMs: bound + 1 }), + 'standard_multiple_choice' ); expect(result.elapsedMs).toBeNull(); - expect(result.timingQuality).toBe('engaged'); expect(result.timingWasWinsorized).toBe(true); }); - - it('uses known bounds and reports no remaining timing when both durations are implausible', () => { - const bound = WINSORIZATION_BOUNDS.mcq; - const result = winsorize(attempt({ elapsedMs: bound + 1, engagedMs: bound + 1 })); - expect(result.elapsedMs).toBeNull(); - expect(result.engagedMs).toBeNull(); - expect(result.timingQuality).toBe('none'); - expect(result.timingWasWinsorized).toBe(true); + it('preserves the reviewed 300-second word-problem bound while response semantics remain multiple choice', () => { + const bound = WINSORIZATION_BOUNDS.word_problem; + expect( + winsorize(attempt({ elapsedMs: bound, engagedMs: bound }), 'word_problem').elapsedMs + ).toBe(bound); + expect(winsorize(attempt({ elapsedMs: bound + 1 }), 'word_problem').elapsedMs).toBeNull(); }); - it('retains wallclock fidelity when only engaged timing is implausible', () => { - const bound = WINSORIZATION_BOUNDS.mcq; - const result = winsorize(attempt({ elapsedMs: bound, engagedMs: bound + 1 })); - - expect(result.elapsedMs).toBe(bound); - expect(result.engagedMs).toBeNull(); - expect(result.timingQuality).toBe('wallclock'); - expect(result.timingWasWinsorized).toBe(true); - }); - - it('preserves a plausible duration and its fidelity', () => { + const bound = WINSORIZATION_BOUNDS.standard_multiple_choice; const result = winsorize( - attempt({ elapsedMs: WINSORIZATION_BOUNDS.mcq, engagedMs: null, timingQuality: 'wallclock' }) + attempt({ elapsedMs: bound, engagedMs: bound + 1 }), + 'standard_multiple_choice' ); - expect(result.elapsedMs).toBe(WINSORIZATION_BOUNDS.mcq); + expect(result.elapsedMs).toBe(bound); + expect(result.engagedMs).toBeNull(); expect(result.timingQuality).toBe('wallclock'); - expect(result.timingWasWinsorized).toBe(false); }); }); diff --git a/packages/narrator/test/request-contract.test.ts b/packages/narrator/test/request-contract.test.ts index 206033d..f6b8294 100644 --- a/packages/narrator/test/request-contract.test.ts +++ b/packages/narrator/test/request-contract.test.ts @@ -10,25 +10,36 @@ import { const bundle: EvidenceBundle = { bundleVersion: 'v1', + behaviorFingerprint: 'behavior-v1', student: { id: 'student-1', firstName: 'Alex' }, - skill: { code: 'TEKS.4.3A', name: 'Unit fractions' }, + scope: { kind: 'skill', skill: { code: 'TEKS.4.3A', name: 'Unit fractions' } }, finding: { dominantCause: 'guessing', severity: 0.7, - confidence: 0.8, + rawConfidence: 0.8, + finalConfidence: 0.8, + confidenceBreakdown: { timingMultiplier: 1, winsorizationMultiplier: 1, conflictMultiplier: 1 }, ruleId: 'guessing.fast-wrong', ruleVersion: 'abc', }, - window: { start: '2026-07-20', end: '2026-07-27', days: 7 }, + window: { + timezone: 'America/Chicago', + start: '2026-07-20', + end: '2026-07-27', + asOf: '2026-07-27', + localDays: 7, + }, attempts: [ { attemptId: 1, ordinal: 1, - date: '2026-07-27', + skill: { code: 'TEKS.4.3A', name: 'Unit fractions' }, + itemType: 'multiple_choice', + submittedAt: '2026-07-27', isCorrect: false, - durationMs: 1000, + elapsedMs: 1000, + engagedMs: 900, timingQuality: 'engaged', - timingWasWinsorized: false, chosenLabel: 'A', misconception: null, hintsUsed: 0, @@ -39,14 +50,11 @@ const bundle: EvidenceBundle = { wrongCount: 1, medianWrongDurationMs: 1000, personalCorrectBaselineMs: 5000, + personalSessionMeanBaselineMs: null, distractorConcentration: null, winsorizedOutCount: 0, }, - ruleEvidence: { - attemptIds: [1], - summary: 'one fast wrong attempt', - values: { fastWrongCount: 1 }, - }, + sessions: [], derived: { wrongOfLastN: { wrong: 1, of: 1 }, speedRatio: 0.2, @@ -57,6 +65,7 @@ const bundle: EvidenceBundle = { conflicts: [], additionalCauses: [], abstentions: [], + languageOptions: { catalogVersion: 'pending', propositions: [], openers: [] }, }; describe('narration generation contract', () => { diff --git a/packages/signal-engine/src/baselines/index.ts b/packages/signal-engine/src/baselines/index.ts index b17f30f..c94009c 100644 --- a/packages/signal-engine/src/baselines/index.ts +++ b/packages/signal-engine/src/baselines/index.ts @@ -1,8 +1,7 @@ import type { Attempt, PersonalBaseline } from '@huddle/core'; import type { RuleConfig } from '../contract.js'; import { perAttemptDurationMs } from '../timing.js'; - -const MS_PER_DAY = 24 * 60 * 60 * 1000; +import { chicagoDaysBefore } from '../windows.js'; function median(values: number[]): number | null { if (values.length === 0) return null; @@ -28,7 +27,7 @@ export function computePersonalBaseline( windowEnd: Date, config: RuleConfig ): PersonalBaseline | null { - const cutoff = new Date(windowEnd.getTime() - config.baseline.windowDays * MS_PER_DAY); + const cutoff = chicagoDaysBefore(windowEnd, config.baseline.windowDays); const windowAttempts = attempts.filter( (attempt) => attempt.studentId === studentId && diff --git a/packages/signal-engine/src/confidence.ts b/packages/signal-engine/src/confidence.ts index 3152350..91da760 100644 --- a/packages/signal-engine/src/confidence.ts +++ b/packages/signal-engine/src/confidence.ts @@ -1,4 +1,4 @@ -import type { Attempt, TimingQuality } from '@huddle/core'; +import type { Attempt, ConfidenceBreakdown, TimingQuality } from '@huddle/core'; import type { RuleConfig } from './contract.js'; function worstTimingQuality(attempts: readonly Attempt[]): TimingQuality { @@ -19,19 +19,38 @@ function winsorizedCount(attempts: readonly Attempt[]): number { return attempts.filter(isWinsorizedOut).length; } +export function confidenceBreakdown( + attempts: readonly Attempt[], + conflictingFamilyCount: number, + config: RuleConfig +): ConfidenceBreakdown { + const timingMultiplier = + attempts.length === 0 ? 1 : config.confidence.tierFactors[worstTimingQuality(attempts)]; + const winsorizedPenalty = Math.min( + config.confidence.maximumWinsorizedPenalty, + winsorizedCount(attempts) * config.confidence.winsorizedPenaltyPerAttempt + ); + return { + timingMultiplier, + winsorizationMultiplier: 1 - winsorizedPenalty, + conflictMultiplier: Math.pow( + 1 - config.confidence.crossFamilyConflictPenalty, + conflictingFamilyCount + ), + }; +} + /** Apply the frozen timing-fidelity and winsorization attenuation uniformly. */ export function attenuateConfidence( baseConfidence: number, attempts: readonly Attempt[], config: RuleConfig ): number { - if (attempts.length === 0) return baseConfidence; - const tierFactor = config.confidence.tierFactors[worstTimingQuality(attempts)]; - const winsorizedPenalty = Math.min( - config.confidence.maximumWinsorizedPenalty, - winsorizedCount(attempts) * config.confidence.winsorizedPenaltyPerAttempt + const breakdown = confidenceBreakdown(attempts, 0, config); + return Math.max( + 0, + Math.min(1, baseConfidence * breakdown.timingMultiplier * breakdown.winsorizationMultiplier) ); - return Math.max(0, Math.min(1, baseConfidence * tierFactor * (1 - winsorizedPenalty))); } export function applyConflictPenalty( diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index 18f1e53..e0ed997 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; +import { timingProfileBoundsMs } from '@huddle/core'; import type { AbsenceSpan, Attempt, @@ -9,7 +11,7 @@ import type { SkillPrereq, } from '@huddle/core'; import { computePersonalBaseline } from './baselines/index.js'; -import { attenuateConfidence, applyConflictPenalty } from './confidence.js'; +import { attenuateConfidence, confidenceBreakdown } from './confidence.js'; import type { RuleConfig, RuleContext, @@ -20,8 +22,9 @@ import type { SkillGraphView, } from './contract.js'; import { assembleEvidenceBundle, type Conflict, type EvidenceBundleContext } from './evidence.js'; -import { ruleVersionForConfig } from './config/thresholds.js'; +import { ruleVersionForConfig, stableStringify } from './config/thresholds.js'; import { resolvedAnswerChoices } from './answer-choices.js'; +import { isInHalfOpenWindow } from './windows.js'; export interface EngineInput { students: { id: string; firstName: string }[]; @@ -75,8 +78,8 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp const attemptsByStudentSkill = new Map(); const attemptsByStudent = new Map(); for (const attempt of input.attempts) { - if (attempt.submittedAt < input.window.start || attempt.submittedAt > input.window.end) - continue; + // Board and mastery windows are half-open: the exact end belongs to the next run. + if (!isInHalfOpenWindow(attempt.submittedAt, input.window)) continue; const key = `${attempt.studentId}::${attempt.skillId}`; const attempts = attemptsByStudentSkill.get(key) ?? []; attempts.push(attempt); @@ -107,10 +110,8 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp const evaluateUnit = (ctx: RuleContext, activeRules: RuleContract[]) => { const evaluated = evaluateContext(ctx, activeRules, nextSignalId); classifications.push(evaluated.classification); - if (evaluated.signal) { - signals.push(evaluated.signal); - nextSignalId++; - } + signals.push(...evaluated.signals); + nextSignalId += evaluated.signals.length; }; if (perSkillRules.length > 0) { @@ -164,8 +165,8 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp function evaluateContext( ctx: RuleContext, rules: RuleContract[], - signalId: number -): { classification: StudentSkillClassification; signal: (Signal & { ruleId: string }) | null } { + firstSignalId: number +): { classification: StudentSkillClassification; signals: (Signal & { ruleId: string })[] } { const ruleOutcomes: RuleEvaluation[] = []; for (const rule of rules) { const missing = findMissingInput(rule, ctx); @@ -176,20 +177,17 @@ function evaluateContext( }); continue; } - let outcome = rule.evaluate(ctx); if (rule.emits === 'disengagement' && outcome.type === 'fired') { const explainingAbsence = absenceExplainsDrop(ctx); - if (explainingAbsence) { + if (explainingAbsence) outcome = { type: 'abstained', reason: { kind: 'absence-explains', span: explainingAbsence }, }; - } } ruleOutcomes.push({ ruleId: rule.id, outcome }); } - const fired = ruleOutcomes.filter((result) => result.outcome.type === 'fired') as Array<{ ruleId: string; outcome: Extract; @@ -205,68 +203,134 @@ function evaluateContext( : 'clear', outcomes: ruleOutcomes, }; - if (fired.length === 0) return { classification, signal: null }; + if (fired.length === 0) return { classification, signals: [] }; - const scored = fired.map((result) => { - const relevant = relevantAttempts(result.ruleId, ctx.attempts); - const confidence = attenuateConfidence(result.outcome.confidence, relevant, ctx.config); - return { + // Every fired rule receives a stable evidence-bearing signal before one is selected for ranking. + const scored = fired + .map((result) => ({ ruleId: result.ruleId, rule: rules.find((rule) => rule.id === result.ruleId)!, outcome: result.outcome, severity: result.outcome.severity, - confidence, - score: result.outcome.severity * confidence, - }; - }); - scored.sort( - (a, b) => b.score - a.score || b.severity - a.severity || a.ruleId.localeCompare(b.ruleId) - ); + relevant: relevantAttempts(result.ruleId, ctx.attempts), + })) + .sort((a, b) => a.ruleId.localeCompare(b.ruleId)); - const dominant = scored[0]!; - const additionalCauses: { cause: RootCause; severity: number }[] = []; - const conflicts: Conflict[] = []; - const conflictingFamilies = new Set(); - for (const other of scored.slice(1)) { - if ( - other.rule.emits !== dominant.rule.emits && - additionalCauses.every((cause) => cause.cause !== other.rule.emits) - ) { - additionalCauses.push({ cause: other.rule.emits, severity: other.severity }); - } - if (other.rule.family !== dominant.rule.family && other.rule.emits !== dominant.rule.emits) { - conflicts.push({ + const signals = scored.map((record, index) => { + const otherFamilies = new Set( + scored + .filter( + (other) => + other.ruleId !== record.ruleId && + other.rule.family !== record.rule.family && + other.rule.emits !== record.rule.emits + ) + .map((other) => other.rule.family) + ); + const breakdown = confidenceBreakdown(record.relevant, otherFamilies.size, ctx.config); + const finalConfidence = Math.max( + 0, + Math.min( + 1, + record.outcome.confidence * + breakdown.timingMultiplier * + breakdown.winsorizationMultiplier * + breakdown.conflictMultiplier + ) + ); + const scope = ctx.skill + ? { kind: 'skill' as const, skill: { code: ctx.skill.id, name: ctx.skill.name } } + : { kind: 'cross-skill' as const }; + const behaviorFingerprint = behaviorFingerprintForRules(ctx.config, rules); + const signal: Signal & { ruleId: string } = { + id: firstSignalId + index, + signalIdentity: [ + ctx.student.id, + scope.kind === 'skill' ? scope.skill.code : 'cross-skill', + record.ruleId, + ctx.window.start.toISOString(), + ctx.window.end.toISOString(), + behaviorFingerprint, + ].join(':'), + studentId: ctx.student.id, + skillId: ctx.skill?.id ?? null, + kind: record.rule.emits, + severity: record.severity, + confidence: finalConfidence, + rawConfidence: record.outcome.confidence, + finalConfidence, + confidenceBreakdown: breakdown, + scope, + behaviorFingerprint, + evidenceFingerprint: undefined, + evidence: {}, + ruleVersion: ruleVersionForConfig(ctx.config), + windowStart: ctx.window.start, + windowEnd: ctx.window.end, + computedAt: ctx.now, + ruleId: record.ruleId, + }; + const additionalCauses = scored + .filter((other) => other.ruleId !== record.ruleId) + .map((other) => ({ + signalId: + firstSignalId + scored.findIndex((candidate) => candidate.ruleId === other.ruleId), + cause: other.rule.emits, + severity: other.severity, + finalConfidence: Math.max( + 0, + Math.min(1, other.outcome.confidence * attenuateConfidence(1, other.relevant, ctx.config)) + ), + ruleId: other.ruleId, + scope: + other.rule.unit === 'student-skill' && ctx.skill + ? { kind: 'skill' as const, skill: { code: ctx.skill.id, name: ctx.skill.name } } + : { kind: 'cross-skill' as const }, + })) + .sort( + (a, b) => + b.severity - a.severity || + b.finalConfidence - a.finalConfidence || + a.ruleId.localeCompare(b.ruleId) + ); + const conflicts: Conflict[] = scored + .filter( + (other) => + other.ruleId !== record.ruleId && + other.rule.family !== record.rule.family && + other.rule.emits !== record.rule.emits + ) + .map((other) => ({ family: other.rule.family, suggestedCause: other.rule.emits, - note: `${other.rule.id} (${other.rule.family}) suggests ${other.rule.emits} while dominant ${dominant.rule.id} suggests ${dominant.rule.emits}`, - }); - conflictingFamilies.add(other.rule.family); - } - } + ruleId: other.ruleId, + })); + signal.evidence = assembleEvidenceBundle(signal, { + ...ctx, + ruleOutcomes, + ruleId: record.ruleId, + additionalCauses, + conflicts, + }); + signal.evidenceFingerprint = JSON.stringify(signal.evidence); + return signal; + }); + return { classification, signals }; +} - const signal: Signal & { ruleId: string } = { - id: signalId, - studentId: ctx.student.id, - skillId: ctx.skill?.id ?? null, - kind: dominant.rule.emits, - severity: dominant.severity, - confidence: applyConflictPenalty(dominant.confidence, conflictingFamilies.size, ctx.config), - evidence: {}, - ruleVersion: ruleVersionForConfig(ctx.config), - windowStart: ctx.window.start, - windowEnd: ctx.window.end, - computedAt: ctx.now, - ruleId: dominant.ruleId, - }; - const evidenceCtx: EvidenceBundleContext = { - ...ctx, - ruleOutcomes, - ruleId: dominant.ruleId, - additionalCauses, - conflicts, - }; - signal.evidence = assembleEvidenceBundle(signal, evidenceCtx); - return { classification, signal }; +function behaviorFingerprintForRules(config: RuleConfig, rules: readonly RuleContract[]): string { + return createHash('sha256') + .update( + stableStringify({ + rules: rules + .map((rule) => ({ id: rule.id, version: rule.version })) + .sort((a, b) => a.id.localeCompare(b.id)), + config, + timingProfileBoundsMs, + timezone: 'America/Chicago', + }) + ) + .digest('hex'); } function buildSkillGraph(skills: Skill[], edges: SkillPrereq[]): SkillGraphView { diff --git a/packages/signal-engine/src/evidence.ts b/packages/signal-engine/src/evidence.ts index 2a08e43..2f2256d 100644 --- a/packages/signal-engine/src/evidence.ts +++ b/packages/signal-engine/src/evidence.ts @@ -1,4 +1,11 @@ -import type { EvidenceBundle, EvidenceFamily, RootCause, Signal } from '@huddle/core'; +import type { + EvidenceBundle, + EvidenceFamily, + EvidenceScope, + ItemType, + RootCause, + Signal, +} from '@huddle/core'; import { MISCONCEPTIONS } from '@huddle/core/seed'; import { isWinsorizedOut } from './confidence.js'; import type { Evidence, RuleContext, RuleOutcome } from './contract.js'; @@ -7,194 +14,189 @@ import { perAttemptDurationMs } from './timing.js'; export interface Conflict { family: EvidenceFamily; suggestedCause: RootCause; - note: string; + ruleId: string; } - export interface EvidenceBundleContext extends RuleContext { ruleOutcomes: { ruleId: string; outcome: RuleOutcome }[]; ruleId: string; - additionalCauses: { cause: RootCause; severity: number }[]; + additionalCauses: EvidenceBundle['additionalCauses']; conflicts: Conflict[]; } - const MS_PER_DAY = 24 * 60 * 60 * 1000; - function median(values: number[]): number | null { - if (values.length === 0) return null; + if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - if (sorted.length % 2 === 1) return sorted[mid]; - return (sorted[mid - 1] + sorted[mid]) / 2; + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2; } +/** Builds the complete, independently addressable bundle for one fired rule signal. */ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContext): EvidenceBundle { - const firedOutcome = ctx.ruleOutcomes.find((r) => r.ruleId === ctx.ruleId)?.outcome; - if (firedOutcome?.type !== 'fired') { - throw new Error(`Dominant rule ${ctx.ruleId} has no fired evidence`); - } + const firedOutcome = ctx.ruleOutcomes.find((result) => result.ruleId === ctx.ruleId)?.outcome; + if (firedOutcome?.type !== 'fired') throw new Error(`Fired rule ${ctx.ruleId} has no evidence`); const firedEvidence = firedOutcome.evidence; const contributingIds = new Set(firedEvidence.attemptIds); const attempts = ctx.attempts .filter((attempt) => contributingIds.has(attempt.id ?? 0)) - .sort((a, b) => a.submittedAt.getTime() - b.submittedAt.getTime()); - const wrongAttempts = attempts.filter((a) => !a.isCorrect); - - const attemptCount = attempts.length; - const wrongCount = wrongAttempts.length; + .sort((a, b) => a.submittedAt.getTime() - b.submittedAt.getTime() || (a.id ?? 0) - (b.id ?? 0)); + const wrongAttempts = attempts.filter((attempt) => !attempt.isCorrect); const wrongDurations = wrongAttempts .map(perAttemptDurationMs) - .filter((d): d is number => d != null); - const medianWrongDurationMs = median(wrongDurations); - + .filter((value): value is number => value != null); const wrongKeyCounts: Record = {}; - let wrongWithKey = 0; - for (const a of wrongAttempts) { - if (a.answerGiven) { - wrongWithKey += 1; - wrongKeyCounts[a.answerGiven.key] = (wrongKeyCounts[a.answerGiven.key] ?? 0) + 1; - } - } - const distractorConcentration = - wrongWithKey > 0 ? Math.max(...Object.values(wrongKeyCounts)) / wrongWithKey : null; - - const winsorizedOutCount = attempts.filter(isWinsorizedOut).length; - + for (const attempt of wrongAttempts) + if (attempt.answerGiven) + wrongKeyCounts[attempt.answerGiven.key] = (wrongKeyCounts[attempt.answerGiven.key] ?? 0) + 1; + const wrongWithKey = Object.values(wrongKeyCounts).reduce((total, count) => total + count, 0); const lastN = Math.min(ctx.config.evidence.wrongOfLastN, attempts.length); - const lastNWrong = attempts.slice(-lastN).filter((a) => !a.isCorrect).length; - + const lastNWrong = attempts.slice(-lastN).filter((attempt) => !attempt.isCorrect).length; let consecutiveWrong = 0; - for (let i = attempts.length - 1; i >= 0; i--) { - if (!attempts[i].isCorrect) consecutiveWrong += 1; - else break; - } - - let speedRatio: number | null = null; - if (ctx.baseline?.correctPaceMs && medianWrongDurationMs != null) { - speedRatio = medianWrongDurationMs / ctx.baseline.correctPaceMs; - } - - let daysSinceFirstAttempt: number | null = null; - if (attempts.length > 0) { - const first = attempts[0].submittedAt; - const days = Math.round((signal.windowEnd.getTime() - first.getTime()) / MS_PER_DAY); - if (days > 0) daysSinceFirstAttempt = days; - } - - const bundleAttempts = attempts.map((a, i) => { - const item = a.itemId in ctx.items ? ctx.items[a.itemId] : null; - const chosen = item?.choices.find((c) => c.key === a.answerGiven?.key); - const misconception = - !a.isCorrect && chosen?.misconceptionId - ? (MISCONCEPTIONS[chosen.misconceptionId] ?? chosen.misconceptionId) - : null; + for (let index = attempts.length - 1; index >= 0 && !attempts[index]!.isCorrect; index--) + consecutiveWrong++; + const bundleAttempts = attempts.map((attempt, index) => { + const item = ctx.items[attempt.itemId]; + const chosen = item?.choices.find((choice) => choice.key === attempt.answerGiven?.key); return { - attemptId: a.id ?? 0, - ordinal: i + 1, - date: a.submittedAt.toISOString(), - isCorrect: a.isCorrect, - durationMs: perAttemptDurationMs(a), - timingQuality: a.timingQuality, - timingWasWinsorized: a.timingWasWinsorized, - chosenLabel: a.answerGiven?.key ?? null, - misconception, - hintsUsed: a.hintsUsed, + attemptId: attempt.id ?? 0, + ordinal: index + 1, + skill: ctx.skill + ? { code: ctx.skill.id, name: ctx.skill.name } + : { code: attempt.skillId, name: attempt.skillId }, + // The engine receives resolved item metadata from the compiler; legacy in-memory fixtures are MCQ. + itemType: 'multiple_choice' as ItemType, + submittedAt: attempt.submittedAt.toISOString(), + isCorrect: attempt.isCorrect, + elapsedMs: attempt.elapsedMs, + engagedMs: attempt.engagedMs, + timingQuality: attempt.timingQuality, + chosenLabel: attempt.answerGiven?.key ?? null, + misconception: + !attempt.isCorrect && chosen?.misconceptionId + ? (MISCONCEPTIONS[chosen.misconceptionId] ?? chosen.misconceptionId) + : null, + hintsUsed: attempt.hintsUsed, }; }); - - const prereqCheck = buildPrerequisiteCheck(ctx, firedEvidence); - + const scope: EvidenceScope = + signal.scope ?? + (ctx.skill + ? { kind: 'skill', skill: { code: ctx.skill.id, name: ctx.skill.name } } + : { kind: 'cross-skill' }); + const daysSinceFirst = attempts.length + ? 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, + }); + } + } return { bundleVersion: signal.ruleVersion, - // Project explicitly: callers pass full `Student` rows, and structural typing - // would otherwise carry guideId/grade past the contract's first-name-only PII limit. + behaviorFingerprint: signal.behaviorFingerprint ?? signal.ruleVersion, student: { id: ctx.student.id, firstName: ctx.student.firstName }, - // A cross-skill finding has no single skill to cite; say so rather than naming one. - skill: ctx.skill - ? { code: ctx.skill.id, name: ctx.skill.name } - : { code: '—', name: 'all skills' }, + scope, finding: { dominantCause: signal.kind, severity: signal.severity, - confidence: signal.confidence, + rawConfidence: signal.rawConfidence ?? signal.confidence, + finalConfidence: signal.finalConfidence ?? signal.confidence, + confidenceBreakdown: signal.confidenceBreakdown ?? { + timingMultiplier: 1, + winsorizationMultiplier: 1, + conflictMultiplier: 1, + }, ruleId: ctx.ruleId, ruleVersion: signal.ruleVersion, }, window: { + timezone: 'America/Chicago', start: signal.windowStart.toISOString(), end: signal.windowEnd.toISOString(), - days: Math.max( - 1, - Math.round((signal.windowEnd.getTime() - signal.windowStart.getTime()) / MS_PER_DAY) + asOf: signal.windowEnd.toISOString(), + localDays: Math.round( + (signal.windowEnd.getTime() - signal.windowStart.getTime()) / MS_PER_DAY ), }, attempts: bundleAttempts, + sessions: [...sessionRows.values()].sort((a, b) => a.sessionId - b.sessionId), computed: { - attemptCount, - wrongCount, - medianWrongDurationMs, + attemptCount: attempts.length, + wrongCount: wrongAttempts.length, + medianWrongDurationMs: median(wrongDurations), personalCorrectBaselineMs: ctx.baseline?.correctPaceMs ?? null, - distractorConcentration, - winsorizedOutCount, - }, - ruleEvidence: { - attemptIds: [...firedEvidence.attemptIds], - summary: firedEvidence.summary, - values: { ...firedEvidence.values }, + personalSessionMeanBaselineMs: ctx.baseline?.sessionMeanPaceMs ?? null, + distractorConcentration: wrongWithKey + ? Math.max(...Object.values(wrongKeyCounts)) / wrongWithKey + : null, + winsorizedOutCount: attempts.filter(isWinsorizedOut).length, }, derived: { - wrongOfLastN: attempts.length > 0 ? { wrong: lastNWrong, of: lastN } : null, - speedRatio, - consecutiveWrong: consecutiveWrong > 0 ? consecutiveWrong : null, - daysSinceFirstAttempt, + wrongOfLastN: attempts.length ? { wrong: lastNWrong, of: lastN } : null, + speedRatio: + ctx.baseline?.correctPaceMs && median(wrongDurations) != null + ? median(wrongDurations)! / ctx.baseline.correctPaceMs + : null, + consecutiveWrong: consecutiveWrong || null, + daysSinceFirstAttempt: daysSinceFirst && daysSinceFirst > 0 ? daysSinceFirst : null, }, - prerequisiteCheck: prereqCheck, + prerequisiteCheck: buildPrerequisiteCheck(ctx, firedEvidence), conflicts: ctx.conflicts, additionalCauses: ctx.additionalCauses, abstentions: ctx.ruleOutcomes - .filter((r) => r.outcome.type === 'abstained') - .map((r) => { - const outcome = r.outcome; - if (outcome.type !== 'abstained') throw new Error('unreachable'); - return { ruleId: r.ruleId, reason: outcome.reason }; - }), + .filter((result) => result.outcome.type === 'abstained') + .map((result) => ({ + ruleId: result.ruleId, + reason: (result.outcome as Extract).reason, + })), + // Catalog ownership is later; an empty closed set is explicit rather than invented language. + languageOptions: { catalogVersion: 'pending', propositions: [], openers: [] }, }; } - +function stableSessionId(value: string): number { + let hash = 0; + for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) | 0; + return Math.abs(hash); +} function buildPrerequisiteCheck( ctx: EvidenceBundleContext, - firedEvidence: Evidence | undefined + firedEvidence: Evidence ): EvidenceBundle['prerequisiteCheck'] { - const prereqValues = firedEvidence?.values; - if (prereqValues && typeof prereqValues.prereqId === 'string') { + const values = firedEvidence.values; + if (typeof values.prereqId === 'string') return { - skillCode: prereqValues.prereqId as string, - skillName: (prereqValues.prereqName as string) ?? (prereqValues.prereqId as string), - masteryValue: prereqValues.masteryValue as number | null, - isKnown: prereqValues.isKnown as boolean, - verdict: prereqValues.verdict as 'adequate' | 'weak' | 'unknown', + skillCode: values.prereqId, + skillName: typeof values.prereqName === 'string' ? values.prereqName : values.prereqId, + masteryValue: typeof values.masteryValue === 'number' ? values.masteryValue : null, + isKnown: values.isKnown === true, + verdict: + values.verdict === 'adequate' || values.verdict === 'weak' ? values.verdict : 'unknown', }; - } - - if (ctx.skill == null) return null; - const prereqs = ctx.skillGraph.prerequisites(ctx.skill.id); - if (prereqs.length === 0) return null; - - const anchor = ctx.window.end; - const first = prereqs[0]; - const m = ctx.mastery.at(first.prereqId, anchor); - let verdict: 'adequate' | 'weak' | 'unknown' = m.isKnown - ? m.value! >= ctx.config.prerequisite.weakMasteryThreshold - ? 'adequate' - : 'weak' - : 'unknown'; - const skill = ctx.skillGraph.allSkills().find((s) => s.id === first.prereqId); + if (!ctx.skill) return null; + const first = ctx.skillGraph.prerequisites(ctx.skill.id)[0]; + if (!first) return null; + const mastery = ctx.mastery.at(first.prereqId, ctx.window.end); + const skill = ctx.skillGraph.allSkills().find((candidate) => candidate.id === first.prereqId); return { skillCode: first.prereqId, skillName: skill?.name ?? first.prereqId, - masteryValue: m.value, - isKnown: m.isKnown, - verdict, + masteryValue: mastery.value, + isKnown: mastery.isKnown, + verdict: mastery.isKnown + ? mastery.value >= ctx.config.prerequisite.weakMasteryThreshold + ? 'adequate' + : 'weak' + : 'unknown', }; } - export type { EvidenceBundle } from '@huddle/core'; diff --git a/packages/signal-engine/src/rank/index.ts b/packages/signal-engine/src/rank/index.ts index 15d56d6..34f3689 100644 --- a/packages/signal-engine/src/rank/index.ts +++ b/packages/signal-engine/src/rank/index.ts @@ -1,132 +1,95 @@ import type { EvidenceBundle, RootCause, Signal, TriageEntry } from '@huddle/core'; -const ROOT_CAUSES: ReadonlySet = new Set([ - 'guessing', - 'prerequisite_gap', - 'grinding', - 'hint_farming', - 'no_read_retry', - 'decay', - 'disengagement', - 'fine', -]); - interface StudentSignal { studentId: string; signal: Signal; } -/** - * Rank students by their dominant signal. - * - * Tiebreak chain (documented in rule-contract.md and data-model.md): - * 1. severity * confidence descending - * 2. severity descending - * 3. student_id ascending - * 4. skill_id ascending (null last), then kind ascending — the discriminators that - * make the chain total inside one student's group, where student_id is constant. - */ +/** Frozen total comparator: severity, final confidence, rule, skill (cross-skill last), student. */ +export function compareSignals( + a: Signal & { ruleId?: string }, + b: Signal & { ruleId?: string } +): number { + if (b.severity !== a.severity) return b.severity - a.severity; + const confidenceA = a.finalConfidence ?? a.confidence; + const confidenceB = b.finalConfidence ?? b.confidence; + if (confidenceB !== confidenceA) return confidenceB - confidenceA; + const byRule = (a.ruleId ?? a.kind).localeCompare(b.ruleId ?? b.kind); + if (byRule !== 0) return byRule; + const bySkill = compareSkillIds(a.skillId, b.skillId); + if (bySkill !== 0) return bySkill; + return a.studentId.localeCompare(b.studentId); +} + +/** Groups only after all per-rule records have retained a complete evidence bundle. */ export function rankBySeverity(signals: Signal[]): TriageEntry[] { const byStudent = new Map(); - for (const s of signals.filter((signal) => signal.kind !== 'fine')) { - const list = byStudent.get(s.studentId) ?? []; - list.push(s); - byStudent.set(s.studentId, list); + for (const signal of signals.filter((signal) => signal.kind !== 'fine')) { + const list = byStudent.get(signal.studentId) ?? []; + list.push(signal); + byStudent.set(signal.studentId, list); } - const entries: StudentSignal[] = []; - for (const [studentId, list] of byStudent) { - const sorted = list.sort((a, b) => compareSignals(a, b)); - const dominant = sorted[0]; + for (const [studentId, records] of byStudent) { + const dominant = [...records].sort(compareSignals)[0]!; entries.push({ studentId, signal: dominant }); } - - const ranked = entries.sort((a, b) => compareSignals(a.signal, b.signal)); - - return ranked.map(({ studentId, signal }, index) => { - const allSignals = byStudent.get(studentId) ?? []; - // A student can have several signals, each with nested non-dominant rule outcomes. - // Preserve that full union before reducing to the board's one-row summary. - const additionalCandidates = [ - ...allSignals.flatMap(additionalCausesFromEvidence), - ...allSignals - .filter((s) => s.id !== signal.id || s.skillId !== signal.skillId || s.kind !== signal.kind) - .map((s) => ({ cause: s.kind, severity: s.severity })), - ].filter((candidate) => candidate.cause !== signal.kind); - const additionalByCause = new Map(); - for (const candidate of additionalCandidates) { - additionalByCause.set( - candidate.cause, - Math.max(candidate.severity, additionalByCause.get(candidate.cause) ?? 0) - ); - } - const additionalCauses = Array.from(additionalByCause, ([cause, severity]) => ({ - cause, - severity, - })).sort((a, b) => b.severity - a.severity || a.cause.localeCompare(b.cause)); - - return { - id: null, - boardDate: signal.windowEnd, - studentId, - dominantSignalId: signal.id ?? 0, - rank: index + 1, - additionalCauses, - diagnosis: null, - opener: null, - generatedAt: null, - }; - }); + return entries + .sort((a, b) => compareSignals(a.signal, b.signal)) + .map(({ studentId, signal }, index) => { + const additionalCauses = (byStudent.get(studentId) ?? []) + .filter((candidate) => candidate !== signal) + .sort(compareSignals) + .map((candidate) => ({ + signalId: candidate.id ?? 0, + cause: candidate.kind, + severity: candidate.severity, + finalConfidence: candidate.finalConfidence ?? candidate.confidence, + ruleId: (candidate as Signal & { ruleId?: string }).ruleId ?? candidate.kind, + scope: + candidate.scope ?? + (candidate.skillId + ? { + kind: 'skill' as const, + skill: { code: candidate.skillId, name: candidate.skillId }, + } + : { kind: 'cross-skill' as const }), + })); + return { + id: null, + boardDate: signal.windowEnd, + studentId, + dominantSignalId: signal.id ?? 0, + rank: index + 1, + additionalCauses, + diagnosis: null, + opener: null, + generatedAt: null, + }; + }); } export function rankedEvidenceBundle(signal: Signal, entry: TriageEntry): EvidenceBundle { - if (signal.id !== entry.dominantSignalId || signal.studentId !== entry.studentId) { + if (signal.id !== entry.dominantSignalId || signal.studentId !== entry.studentId) throw new Error('Triage entry does not match its dominant signal'); - } - if (signal.evidence == null || typeof signal.evidence !== 'object') { + if (!signal.evidence || typeof signal.evidence !== 'object') throw new Error(`Signal ${signal.id ?? 'unknown'} has no evidence bundle`); - } return { ...(signal.evidence as EvidenceBundle), - additionalCauses: entry.additionalCauses.map((cause) => ({ ...cause })), + additionalCauses: entry.additionalCauses.map((cause) => ({ + signalId: cause.signalId ?? 0, + cause: cause.cause, + severity: cause.severity, + finalConfidence: cause.finalConfidence ?? 0, + ruleId: cause.ruleId ?? cause.cause, + scope: cause.scope ?? { kind: 'cross-skill' }, + })), }; } - -function additionalCausesFromEvidence( - signal: Signal -): Array<{ cause: RootCause; severity: number }> { - if (signal.evidence == null || typeof signal.evidence !== 'object') return []; - const candidates = (signal.evidence as { additionalCauses?: unknown }).additionalCauses; - if (!Array.isArray(candidates)) return []; - return candidates.filter( - (candidate): candidate is { cause: RootCause; severity: number } => - candidate != null && - typeof candidate === 'object' && - typeof (candidate as { cause?: unknown }).cause === 'string' && - ROOT_CAUSES.has((candidate as { cause: string }).cause) && - typeof (candidate as { severity?: unknown }).severity === 'number' && - Number.isFinite((candidate as { severity: number }).severity) - ); -} - -function compareSignals(a: Signal, b: Signal): number { - const scoreA = a.severity * a.confidence; - const scoreB = b.severity * b.confidence; - if (scoreB !== scoreA) return scoreB - scoreA; - if (b.severity !== a.severity) return b.severity - a.severity; - const byStudent = a.studentId.localeCompare(b.studentId); - if (byStudent !== 0) return byStudent; - const bySkill = compareSkillIds(a.skillId, b.skillId); - if (bySkill !== 0) return bySkill; - return a.kind.localeCompare(b.kind); -} - -/** A cross-skill signal has no skill code to order by, so it sorts after every skill-scoped one. */ function compareSkillIds(a: string | null, b: string | null): number { if (a === b) return 0; if (a === null) return 1; if (b === null) return -1; return a.localeCompare(b); } - export type { RootCause, Signal, TriageEntry }; diff --git a/packages/signal-engine/src/severity.ts b/packages/signal-engine/src/severity.ts index 644c291..11810c3 100644 --- a/packages/signal-engine/src/severity.ts +++ b/packages/signal-engine/src/severity.ts @@ -2,7 +2,7 @@ import type { RootCause } from '@huddle/core'; import type { RuleConfig } from './contract.js'; import { URGENCY_BY_CAUSE } from './config/thresholds.js'; -// Frozen per-cause urgency weights are versioned with every other rule control. +// Frozen centralized urgency weights are fingerprinted synthetic-demo policy, not per-guide settings. export const URGENCY: Record = URGENCY_BY_CAUSE; /** diff --git a/packages/signal-engine/src/windows.ts b/packages/signal-engine/src/windows.ts index 78f854d..ea56c0b 100644 --- a/packages/signal-engine/src/windows.ts +++ b/packages/signal-engine/src/windows.ts @@ -1,14 +1,73 @@ import { RULE_CONFIG } from './config/thresholds.js'; import type { RuleConfig } from './contract.js'; -/** Deterministic UTC boundaries: one scoring week and a separate trailing baseline month. */ +export const BOARD_TIMEZONE = 'America/Chicago' as const; +const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: BOARD_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', +}); + +type LocalDate = { year: number; month: number; day: number }; +function localDate(instant: Date): LocalDate { + const parts = Object.fromEntries( + formatter + .formatToParts(instant) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]) + ); + return { year: Number(parts.year), month: Number(parts.month), day: Number(parts.day) }; +} +/** Converts a Chicago civil midnight to its UTC instant without assuming a 24-hour day. */ +export function chicagoMidnight(date: LocalDate): Date { + const desired = Date.UTC(date.year, date.month - 1, date.day, 0, 0, 0); + const projected = localDate(new Date(desired)); + // Reconstruct the Chicago wall clock at the candidate instant to obtain the UTC offset. + const parts = Object.fromEntries( + formatter + .formatToParts(new Date(desired)) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]) + ); + const observed = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + const offset = observed - desired; + void projected; + return new Date(desired - offset); +} +function addLocalDays(date: LocalDate, days: number): LocalDate { + const value = new Date(Date.UTC(date.year, date.month - 1, date.day + days)); + return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1, day: value.getUTCDate() }; +} +export function chicagoDaysBefore(anchor: Date, days: number): Date { + return chicagoMidnight(addLocalDays(localDate(anchor), -days)); +} + +/** Chicago-local [start,end) board and baseline boundaries. DST changes elapsed milliseconds, never civil dates. */ +export function isInHalfOpenWindow(instant: Date, window: { start: Date; end: Date }): boolean { + return instant >= window.start && instant < window.end; +} + export function boardWindows( anchor: Date, config: RuleConfig = RULE_CONFIG -): { windowStart: Date; historyStart: Date } { - const windowStart = new Date(anchor); - windowStart.setUTCDate(anchor.getUTCDate() - config.board.windowDays); - const historyStart = new Date(windowStart); - historyStart.setUTCDate(windowStart.getUTCDate() - config.baseline.windowDays); - return { windowStart, historyStart }; +): { windowStart: Date; historyStart: Date; windowEnd: Date } { + const endDate = localDate(anchor); + const windowEnd = chicagoMidnight(endDate); + const windowStart = chicagoMidnight(addLocalDays(endDate, -config.board.windowDays)); + const historyStart = chicagoMidnight( + addLocalDays(addLocalDays(endDate, -config.board.windowDays), -config.baseline.windowDays) + ); + return { windowStart, historyStart, windowEnd }; } diff --git a/packages/signal-engine/test/board-window.test.ts b/packages/signal-engine/test/board-window.test.ts index 1cf879f..a1eb6c4 100644 --- a/packages/signal-engine/test/board-window.test.ts +++ b/packages/signal-engine/test/board-window.test.ts @@ -1,30 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { boardWindows } from '../src/windows.js'; -import { RULE_CONFIG } from '../src/config/thresholds.js'; -import type { RuleConfig } from '../src/contract.js'; +import { boardWindows, isInHalfOpenWindow } from '../src/windows.js'; -const day = 24 * 60 * 60 * 1000; - -describe('board windows', () => { - it('separates the seven-day scoring period from its preceding thirty-day baseline', () => { - const anchor = new Date('2026-07-28T23:59:59.999Z'); - const { windowStart, historyStart } = boardWindows(anchor); - - expect(anchor.getTime() - windowStart.getTime()).toBe(7 * day); - expect(windowStart.getTime() - historyStart.getTime()).toBe(30 * day); - expect(windowStart.toISOString()).toBe('2026-07-21T23:59:59.999Z'); - expect(historyStart.toISOString()).toBe('2026-06-21T23:59:59.999Z'); - }); - - it('uses the injected board and baseline configuration', () => { - const anchor = new Date('2026-07-28T23:59:59.999Z'); - const config = structuredClone(RULE_CONFIG) as RuleConfig; - config.board.windowDays = 14; - config.baseline.windowDays = 21; - - const { windowStart, historyStart } = boardWindows(anchor, config); - - expect(anchor.getTime() - windowStart.getTime()).toBe(14 * day); - expect(windowStart.getTime() - historyStart.getTime()).toBe(21 * day); +describe('Chicago half-open board windows', () => { + it.each([ + ['spring DST', '2026-03-09T05:00:00.000Z', '2026-03-02T06:00:00.000Z'], + ['fall DST', '2026-11-02T06:00:00.000Z', '2026-10-26T05:00:00.000Z'], + ])('derives %s civil midnights with IANA arithmetic', (_name, end, start) => { + const window = boardWindows(new Date(end)); + expect(window.windowEnd.toISOString()).toBe(end); + expect(window.windowStart.toISOString()).toBe(start); + expect( + isInHalfOpenWindow(window.windowStart, { start: window.windowStart, end: window.windowEnd }) + ).toBe(true); + expect( + isInHalfOpenWindow(new Date(window.windowEnd.getTime() - 1), { + start: window.windowStart, + end: window.windowEnd, + }) + ).toBe(true); + expect( + isInHalfOpenWindow(window.windowEnd, { start: window.windowStart, end: window.windowEnd }) + ).toBe(false); }); }); diff --git a/packages/signal-engine/test/determinism.test.ts b/packages/signal-engine/test/determinism.test.ts index d017055..62bafde 100644 --- a/packages/signal-engine/test/determinism.test.ts +++ b/packages/signal-engine/test/determinism.test.ts @@ -98,10 +98,7 @@ describe('determinism', () => { const bundle = output.signals[0]?.evidence as EvidenceBundle; expect(bundle.attempts.map((entry) => entry.attemptId)).toEqual([2]); - expect(bundle.ruleEvidence).toEqual({ - attemptIds: [2], - summary: 'volume fell below the expected level', - values: { expectedVolume: 8, dropFraction: 0.75 }, - }); + expect(bundle.finding.ruleId).toBe('test.exact-evidence'); + expect(bundle.behaviorFingerprint).toBeTruthy(); }); }); diff --git a/packages/signal-engine/test/half-open-engine.test.ts b/packages/signal-engine/test/half-open-engine.test.ts new file mode 100644 index 0000000..a6a364f --- /dev/null +++ b/packages/signal-engine/test/half-open-engine.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import type { RuleContract } from '../src/contract.js'; +import { runEngine } from '../src/engine.js'; +import { attempt, defaultItems, defaultSkill, makeContext } from './rule-context.js'; + +const countRule: RuleContract = { + id: 'test.window', + version: 'v1', + emits: 'guessing', + family: 'timing', + urgency: 1, + unit: 'student-skill', + requiredInputs: [], + evaluate: (ctx) => + ctx.attempts.length + ? { + type: 'fired', + severity: 1, + confidence: 1, + evidence: { + attemptIds: ctx.attempts.map((entry) => entry.id ?? 0), + summary: 'in-window', + values: {}, + }, + } + : { type: 'clear' }, +}; + +describe('half-open board/mastery boundary contract', () => { + it('includes exact-start and one-millisecond-before-end, but exact-end belongs to the next run', () => { + const start = new Date('2026-03-02T06:00:00.000Z'); + const end = new Date('2026-03-09T05:00:00.000Z'); + const context = makeContext({ + attempts: [ + attempt({ id: 1, submittedAt: start }), + attempt({ id: 2, submittedAt: new Date(end.getTime() - 1) }), + attempt({ id: 3, submittedAt: end }), + ], + window: { start, end }, + now: end, + }); + const output = runEngine( + { + students: [context.student], + skills: [defaultSkill], + attempts: [...context.attempts], + items: Object.values(defaultItems), + skillPrereqs: [], + mastery: context.mastery, + attendance: [], + attendanceLoaded: true, + config: context.config, + now: end, + window: { start, end }, + }, + [countRule] + ); + const bundle = output.signals[0]!.evidence as import('@huddle/core').EvidenceBundle; + expect(bundle.attempts.map((entry) => entry.attemptId)).toEqual([1, 2]); + expect(bundle.attempts.map((entry) => entry.attemptId)).not.toContain(3); + }); +}); diff --git a/packages/signal-engine/test/per-fired-rule-evidence.test.ts b/packages/signal-engine/test/per-fired-rule-evidence.test.ts new file mode 100644 index 0000000..a31b60d --- /dev/null +++ b/packages/signal-engine/test/per-fired-rule-evidence.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import type { RuleContract } from '../src/contract.js'; +import { runEngine } from '../src/engine.js'; +import { makeContext, attempt, defaultSkill, defaultItems } from './rule-context.js'; + +describe('per-fired-rule evidence identity', () => { + it('retains deterministic complete evidence for every fired rule and addressable additional cause', () => { + const rules: RuleContract[] = ['guessing.fast', 'grinding.slow'].map((id, index) => ({ + id, + version: 'v1', + emits: index ? 'grinding' : 'guessing', + family: index ? 'timing' : 'answer-choice', + urgency: 0.8, + unit: 'student-skill', + requiredInputs: [], + evaluate: () => ({ + type: 'fired' as const, + severity: index ? 0.6 : 0.8, + confidence: index ? 0.7 : 0.9, + evidence: { attemptIds: [1], summary: id, values: {} }, + }), + })); + const context = makeContext({ attempts: [attempt({ id: 1 })] }); + const output = runEngine( + { + students: [context.student], + skills: [defaultSkill], + attempts: [...context.attempts], + items: Object.values(defaultItems), + skillPrereqs: [], + mastery: context.mastery, + attendance: [], + attendanceLoaded: true, + config: context.config, + now: context.now, + window: context.window, + }, + rules + ); + expect(output.signals).toHaveLength(2); + expect(new Set(output.signals.map((signal) => signal.signalIdentity)).size).toBe(2); + expect( + output.signals.every((signal) => signal.behaviorFingerprint?.match(/^[a-f0-9]{64}$/)) + ).toBe(true); + const dominant = output.signals.find((signal) => signal.ruleId === 'guessing.fast')!; + const additional = (dominant.evidence as import('@huddle/core').EvidenceBundle) + .additionalCauses; + expect(additional).toMatchObject([ + { + signalId: output.signals.find((signal) => signal.ruleId === 'grinding.slow')!.id, + ruleId: 'grinding.slow', + cause: 'grinding', + }, + ]); + expect( + ( + output.signals.find((signal) => signal.ruleId === 'grinding.slow')! + .evidence as import('@huddle/core').EvidenceBundle + ).attempts + ).toHaveLength(1); + }); +}); diff --git a/packages/signal-engine/test/rank-tiebreak.test.ts b/packages/signal-engine/test/rank-tiebreak.test.ts index 281a301..b642d93 100644 --- a/packages/signal-engine/test/rank-tiebreak.test.ts +++ b/packages/signal-engine/test/rank-tiebreak.test.ts @@ -70,12 +70,12 @@ describe('rank tiebreak', () => { 2 ); - // A cross-skill signal has no code to order by, so it sorts after every skill-scoped one. - expect(dominantOf([tied(1, null, 'grinding'), tied(2, 'TEKS.4.9B', 'guessing')])).toBe(2); - expect(dominantOf([tied(2, 'TEKS.4.9B', 'guessing'), tied(1, null, 'grinding')])).toBe(2); + // Rule id precedes skill in the target comparator, including cross-skill records. + expect(dominantOf([tied(1, null, 'grinding'), tied(2, 'TEKS.4.9B', 'guessing')])).toBe(1); + expect(dominantOf([tied(2, 'TEKS.4.9B', 'guessing'), tied(1, null, 'grinding')])).toBe(1); }); - it('retains nested causes from non-dominant signals in the board disclosure', () => { + it('retains the addressable non-dominant signal in board disclosure', () => { const signal = ( id: number, kind: 'guessing' | 'grinding', @@ -100,9 +100,8 @@ describe('rank tiebreak', () => { signal(2, 'grinding', 0.6, { additionalCauses: [{ cause: 'decay', severity: 0.4 }] }), ]); - expect(entry?.additionalCauses).toEqual([ - { cause: 'grinding', severity: 0.6 }, - { cause: 'decay', severity: 0.4 }, + expect(entry?.additionalCauses).toMatchObject([ + { signalId: 2, cause: 'grinding', severity: 0.6, finalConfidence: 1 }, ]); }); }); diff --git a/packages/signal-engine/test/ranking.test.ts b/packages/signal-engine/test/ranking.test.ts index 1b8cdd4..46e30e5 100644 --- a/packages/signal-engine/test/ranking.test.ts +++ b/packages/signal-engine/test/ranking.test.ts @@ -37,7 +37,7 @@ describe('ranking', () => { expect(ranked[1].studentId).toBe('student-low'); }); - it('preserves additional causes collapsed into the dominant signal evidence', () => { + it('does not manufacture additional records from legacy summary-only evidence', () => { const signal = { id: 1, studentId: 'student-a', @@ -54,9 +54,7 @@ describe('ranking', () => { computedAt: new Date('2026-07-27T00:00:00Z'), }; - expect(rankBySeverity([signal])[0]?.additionalCauses).toEqual([ - { cause: 'grinding', severity: 0.3 }, - ]); + expect(rankBySeverity([signal])[0]?.additionalCauses).toEqual([]); }); it('projects the final student-wide causes into the narration bundle', () => { @@ -83,9 +81,8 @@ describe('ranking', () => { }; const [entry] = rankBySeverity([dominant, other]); - expect(rankedEvidenceBundle(dominant, entry!).additionalCauses).toEqual([ - { cause: 'grinding', severity: 0.6 }, - { cause: 'decay', severity: 0.4 }, + expect(rankedEvidenceBundle(dominant, entry!).additionalCauses).toMatchObject([ + { signalId: 2, cause: 'grinding', severity: 0.6 }, ]); }); }); diff --git a/packages/signal-engine/test/rule-context.ts b/packages/signal-engine/test/rule-context.ts index 75c53af..f9e5969 100644 --- a/packages/signal-engine/test/rule-context.ts +++ b/packages/signal-engine/test/rule-context.ts @@ -14,6 +14,8 @@ export const defaultItems: Record = { id: 'mcq:TEKS.4.3E-01', skillId: 'TEKS.4.3E', difficulty: 0.7, + itemType: 'multiple_choice', + timingProfile: 'standard_multiple_choice', choices: [ { key: 'A', isCorrect: false, misconceptionId: 'ADDS_DENOMINATORS' }, { key: 'B', isCorrect: true }, @@ -27,6 +29,8 @@ export const easyItem: Item = { id: 'mcq:TEKS.4.2A-01', skillId: 'TEKS.4.2A', difficulty: 0.3, + itemType: 'multiple_choice', + timingProfile: 'standard_multiple_choice', choices: [ { key: 'A', isCorrect: true }, { key: 'B', isCorrect: false, misconceptionId: 'IGNORES_PLACE_VALUE' }, diff --git a/packages/signal-engine/test/scenario-guessing.test.ts b/packages/signal-engine/test/scenario-guessing.test.ts index e73bd20..d4eb079 100644 --- a/packages/signal-engine/test/scenario-guessing.test.ts +++ b/packages/signal-engine/test/scenario-guessing.test.ts @@ -8,9 +8,12 @@ describe('scenario: guessing-not-prereq', () => { 'TEKS.4.3C': { value: 0.8, isKnown: true }, }); - expect(result.signals.length).toBe(1); - const signal = result.signals[0]; - expect(signal.kind).toBe('guessing'); - expect(signal.ruleId).toBe('guessing.fast-wrong-vs-baseline'); + expect(result.signals.length).toBeGreaterThan(1); + const signal = result.signals.find( + (candidate) => candidate.ruleId === 'guessing.fast-wrong-vs-baseline' + ); + expect(signal?.kind).toBe('guessing'); + expect(signal?.signalIdentity).toBeTruthy(); + expect(signal?.evidence).toBeTruthy(); }); }); diff --git a/scripts/check-determinism.ts b/scripts/check-determinism.ts index de62c82..f888001 100644 --- a/scripts/check-determinism.ts +++ b/scripts/check-determinism.ts @@ -135,6 +135,12 @@ export async function checkDeterminism(root: string = defaultRoot): Promise = [ + { packageName: '@huddle/core', forbidden: ['@anthropic-ai/sdk', 'pg', '@huddle/db'] }, + { + packageName: '@huddle/application', + forbidden: ['@anthropic-ai/sdk', 'pg', '@huddle/db', 'next', '@supabase/ssr'], + }, + { packageName: '@huddle/ingest', forbidden: ['pg', '@huddle/db'] }, { packageName: '@huddle/signal-engine', forbidden: ['@anthropic-ai/sdk', 'pg', '@huddle/db'] }, { packageName: '@huddle/narrator', forbidden: ['pg', '@huddle/db'] }, { @@ -152,6 +158,22 @@ export async function checkDeterminism(root: string = defaultRoot): Promise = [ + { + label: 'core', + dir: join(root, 'packages/core/src'), + pattern: /(?:@anthropic-ai\/sdk|(?:from|import\()\s*['"]pg['"]|@huddle\/db(?:\/|['"]))/, + }, + { + label: 'application', + dir: join(root, 'packages/application/src'), + pattern: + /(?:@anthropic-ai\/sdk|(?:from|import\()\s*['"]pg['"]|@huddle\/db|(?:from|import\()\s*['"]next['"]|@supabase\/ssr)/, + }, + { + label: 'ingest', + dir: join(root, 'packages/ingest/src'), + pattern: /(?:(?:from|import\()\s*['"]pg['"]|@huddle\/db(?:\/|['"]))/, + }, { label: 'signal-engine', dir: join(root, 'packages/signal-engine/src'), diff --git a/specs/001-huddle-triage-board/contracts/application-interfaces.md b/specs/001-huddle-triage-board/contracts/application-interfaces.md index b889156..942b0ba 100644 --- a/specs/001-huddle-triage-board/contracts/application-interfaces.md +++ b/specs/001-huddle-triage-board/contracts/application-interfaces.md @@ -38,9 +38,12 @@ message for invalid credentials or unavailable Auth. Browser code may receive on URL and publishable Auth key through an encapsulated Auth-only client. It receives no `DATABASE_URL`, service-role key, database credential, SQL client, or unrestricted Supabase client, and cannot call `.from(...)` or return the Auth client to product -code. Every application read or mutation first verifies the Supabase session on the server and -resolves exactly one synthetic `GuideAccess`. Missing, invalid, ambiguous, wrong-role, cross-guide, -or non-synthetic scope fails closed without roster, board-freshness, import, or evidence data. +code. Every application read or mutation first calls the server-only composition boundary +`resolveGuideAccess(): Promise`. It verifies the Supabase session and resolves +exactly one synthetic `GuideAccess`; browser inputs never supply trusted project, roster, guide, +timezone, or data scope. Browser Auth credentials remain separate from service database credentials. +Missing, invalid, ambiguous, wrong-role, cross-guide, or non-synthetic scope fails closed without +roster, board-freshness, import, or evidence data. ## The four interfaces @@ -379,7 +382,10 @@ The Importer performs a two-step workflow: Fatal file/scope/schema errors fail before commit. Row-level unmapped/rejected records do not discard valid neighbors; the receipt reports all four counts and a safe import ID. The UI offers a separate manual refresh after success rather than coupling ingest to ranking. No real-data upload path exists, -and this synthetic payload policy does not decide future real-data retention. +and this synthetic payload policy does not decide future real-data retention. Legacy synthetic +**derived** attempts and boards are rebuilt from canonical synthetic fixtures through the target +Importer and BoardCompiler; missing provenance is never manufactured. Any retained legacy row is +explicitly legacy/non-displayable and cannot become a `board_head`. ## Next.js bindings and negative boundaries @@ -397,13 +403,12 @@ and this synthetic payload policy does not decide future real-data retention. ## Future safety gates intentionally left open -- **Evaluation release promotion**: constitution merge gates remain mandatory. Whether an internal - quick demo may be promoted to an external demo/pilot before the full accepted eval release bundle, - and who may approve an acknowledged regression, remains a captain decision. Until closed, the UI - and docs make no accuracy-backed release claim. -- **Severity governance**: current urgency values stay fingerprinted defaults. The approver, - review cadence, evidence required for a change, and pilot override process remain unset; no pilot - may treat the table as governed policy until this gate closes. +- **Portfolio-demo evaluation gate**: the hosted synthetic work sample must run grounding and + deterministic-fallback regressions and make no diagnosis-accuracy claim. The full simulator and + accuracy bundle remains mandatory before any pilot or accuracy-backed public claim. +- **Severity governance**: centralized urgency values are fingerprinted synthetic-demo policy. + Per-guide customization and learned weights are out of scope; no pilot may treat the table as + governed policy until its approver, review cadence, evidence standard, and override process exist. - **Real-data retention**: no real student data is admitted. Retention/deletion periods, backup behavior, subject requests, quarantine/raw-payload handling, audit access, and incident response must be approved together with RLS before real data or a multi-guide pilot. diff --git a/specs/001-huddle-triage-board/contracts/ingest-adapter.md b/specs/001-huddle-triage-board/contracts/ingest-adapter.md index f200892..f617bd5 100644 --- a/specs/001-huddle-triage-board/contracts/ingest-adapter.md +++ b/specs/001-huddle-triage-board/contracts/ingest-adapter.md @@ -99,7 +99,7 @@ For each detected delivery, `packages/ingest/src/pipeline.ts` performs exactly t 1. adapter `parse` to drafts and source parse failures; 2. attach source/version, `ctx.receivedAt`, and dedupe identities inside ingest; 3. map each `vendorSkill` through `adapter.mapSkill`; -4. resolve the vendor item to a seeded internal item and its required `item_type`; +4. resolve the vendor item to a seeded internal item, its response-semantic `item_type`, and its separate fingerprinted timing profile; 5. apply the frozen per-item-type winsorization bound exactly once; 6. resolve the internal learning session and pseudonymous student; 7. call the injected persistence port; the composition root's `packages/db` implementation @@ -134,6 +134,16 @@ It does not create an `attempt`. Replays do not duplicate it, and reporting can accepts only synthetic fixtures, so no real student payload is retained. Malformed/unsupported records use the same reporting owner with an appropriate failure kind when a stable event ID exists. +## Fail-closed synthetic conflict policy + +For the bounded `synthetic-csv-v1` path, rows are grouped by the full event identity and compared as +canonical normalized values. Canonically identical duplicates collapse to one persisted fact. +Divergent rows with the same identity reject the entire identity group atomically, independent of +input order; divergent repeated session aggregates reject their session group the same way. A conflict +with an already stored identity rejects incoming divergent content. Every item, skill, student, and +answer reference must resolve to fixed seeded synthetic data in the authorized scope. Arbitrary +rejected raw rows are not retained; bounded allow-listed normalized issue fields are sufficient. + ## Timing honesty and boundaries - `engagedMs` exists only with genuine client instrumentation. diff --git a/specs/001-huddle-triage-board/spec.md b/specs/001-huddle-triage-board/spec.md index ddbed38..694880f 100644 --- a/specs/001-huddle-triage-board/spec.md +++ b/specs/001-huddle-triage-board/spec.md @@ -750,13 +750,15 @@ The executable interface/state contract is ### Open future gates — intentionally unresolved -- **Evaluation release promotion**: the seven constitutional merge gates remain mandatory. Whether an - internal quick demo may be promoted to an external demo/pilot before the full accepted eval release - bundle, who may acknowledge a regression, and what evidence that promotion requires remain a - captain decision. Until then, no accuracy-backed release claim is permitted. -- **Severity governance**: the current urgency table remains a fingerprinted implementation default. - The approver, review cadence, evidence standard, and pilot override process are not selected; it - must not be treated as governed intervention policy before this gate closes. +- **Portfolio-demo evaluation gate (captain-approved)**: the hosted synthetic work sample may ship + after executable grounding and deterministic-fallback regressions pass, and it must make **no + diagnosis-accuracy claim**. This scoped portfolio gate does not weaken determinism, grounding, or + privacy. The full simulator and accuracy bundle remains mandatory before any pilot or any + accuracy-backed public claim. +- **Severity governance**: the current urgency table is a fingerprinted, centralized synthetic-demo + policy only. No per-guide customization or learned weights are permitted. The approver, review + cadence, evidence standard, and pilot override process are not selected; it must not be treated as + governed intervention policy before this gate closes. - **Real-data retention**: no real student data may enter Huddle. Retention/deletion periods, backups, subject requests, raw/quarantine handling, audit access, and incident response must be approved together with RLS before real data or a multi-guide pilot. diff --git a/specs/001-huddle-triage-board/tasks.md b/specs/001-huddle-triage-board/tasks.md index b45d535..b877ab2 100644 --- a/specs/001-huddle-triage-board/tasks.md +++ b/specs/001-huddle-triage-board/tasks.md @@ -346,7 +346,7 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra 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. -- [ ] T129 Forward migration plus grant-free `EvidenceReader.readEntry`, fresh authorized +- [ ] T129 **After T121** forward migration plus grant-free `EvidenceReader.readEntry`, fresh authorized `EvidenceReader.openEntry`, its signed five-minute one-use grant, and opening-bound renewal capability, `EvidenceReader.acknowledgeVisibleOpen`, and separate `report_acknowledgement_grant_use` replay ledger. Prefetch never calls `openEntry` or receives a @@ -547,7 +547,7 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra detector/ingest behavior. - Within the focused shell: T112 precedes every interface implementation; T113–T116 authorize every operation; T117/T118 precede T119/T120; T121/T122 precede T123–T125; T126 precedes T056/T127; - T063/T128 precede T129/T130. T133 and T135/T136 run only after all focused acceptance tests exist. + T063/T128 and T121 (immutable run/entry/finding identities) precede T129/T130. T133 and T135/T136 run only after all focused acceptance tests exist. - Nightly and manual refresh converge at `BoardCompiler`; `BoardReader` reads only `board_head`. `Importer` commits before an explicit refresh. Evidence read is side-effect free; acknowledgment is a separate post-visible-open action. diff --git a/tsconfig.json b/tsconfig.json index d3f7783..6b26e36 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "include": [], "references": [ { "path": "./packages/core" }, + { "path": "./packages/application" }, { "path": "./packages/db" }, { "path": "./packages/ingest" }, { "path": "./packages/signal-engine" }, From 6a25b61e4126071e5de9bb2e0a0b46ae7fc19ff8 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 12:40:18 -0500 Subject: [PATCH 2/7] no-mistakes(review): Fix deterministic signal boundaries and legacy containment --- db/migrations/007_shared_foundation.sql | 12 +- .../test/shared-foundation-migration.test.ts | 6 +- packages/signal-engine/src/confidence.ts | 74 ++-- .../signal-engine/src/config/thresholds.ts | 20 +- packages/signal-engine/src/contract.ts | 2 +- packages/signal-engine/src/engine.ts | 319 ++++++++++-------- packages/signal-engine/src/index.ts | 2 +- packages/signal-engine/src/rules/decay.ts | 2 +- .../src/rules/grinding-session.ts | 2 +- .../signal-engine/src/rules/prereq-blocked.ts | 2 +- .../test/confidence-conflict.test.ts | 80 +++++ .../test/grinding-session-engine.test.ts | 14 +- .../test/half-open-engine.test.ts | 15 +- .../test/per-fired-rule-evidence.test.ts | 100 +++++- packages/signal-engine/test/rule-context.ts | 6 +- packages/signal-engine/test/rules.test.ts | 2 +- scripts/nightly.ts | 35 +- 17 files changed, 490 insertions(+), 203 deletions(-) create mode 100644 packages/signal-engine/test/confidence-conflict.test.ts diff --git a/db/migrations/007_shared_foundation.sql b/db/migrations/007_shared_foundation.sql index 2d7f51b..55160a9 100644 --- a/db/migrations/007_shared_foundation.sql +++ b/db/migrations/007_shared_foundation.sql @@ -10,11 +10,21 @@ ALTER TABLE item CHECK (item_type IN ('multiple_choice', 'numeric', 'short_text')), ADD COLUMN IF NOT EXISTS timing_profile text NOT NULL DEFAULT 'standard_multiple_choice' CHECK (timing_profile IN ('standard_multiple_choice', 'word_problem')); +UPDATE item +SET timing_profile = 'word_problem' +WHERE id IN ( + 'wp:TEKS.4.4H-01', + 'wp:TEKS.4.5A-01', + 'wp:TEKS.4.8C-01', + 'wp:TEKS.4.9B-01' +); -- Existing date-keyed derived rows lack target receipt/rule/evidence provenance. They remain -- historical only and cannot be returned as an active board while the target compiler recomputes. ALTER TABLE triage_entry - ADD COLUMN IF NOT EXISTS legacy_non_displayable boolean NOT NULL DEFAULT false; + ADD COLUMN IF NOT EXISTS legacy_non_displayable boolean NOT NULL DEFAULT true; +ALTER TABLE triage_entry + ALTER COLUMN legacy_non_displayable SET DEFAULT true; UPDATE triage_entry SET legacy_non_displayable = true WHERE legacy_non_displayable = false; -- Mastery belongs to the same half-open [start,end) semantics as board compilation. diff --git a/packages/db/test/shared-foundation-migration.test.ts b/packages/db/test/shared-foundation-migration.test.ts index 5f1d49c..056552c 100644 --- a/packages/db/test/shared-foundation-migration.test.ts +++ b/packages/db/test/shared-foundation-migration.test.ts @@ -13,8 +13,12 @@ describe('shared foundation migration reservation', () => { /008_import_receipt_session[\s\S]*009_board_run_head[\s\S]*010_evidence_acknowledgment/ ); expect(sql).toContain('legacy/non-displayable'); - expect(sql).toContain('legacy_non_displayable boolean NOT NULL DEFAULT false'); + expect(sql).toContain('legacy_non_displayable boolean NOT NULL DEFAULT true'); + expect(sql).toContain('ALTER COLUMN legacy_non_displayable SET DEFAULT true'); expect(sql).toContain('UPDATE triage_entry SET legacy_non_displayable = true'); + expect(sql).toMatch( + /SET timing_profile = 'word_problem'[\s\S]*'wp:TEKS\.4\.4H-01'[\s\S]*'wp:TEKS\.4\.5A-01'[\s\S]*'wp:TEKS\.4\.8C-01'[\s\S]*'wp:TEKS\.4\.9B-01'/ + ); expect(sql).toContain('WHERE a.submitted_at < anchor'); }); }); diff --git a/packages/signal-engine/src/confidence.ts b/packages/signal-engine/src/confidence.ts index 91da760..e710dfa 100644 --- a/packages/signal-engine/src/confidence.ts +++ b/packages/signal-engine/src/confidence.ts @@ -1,9 +1,36 @@ import type { Attempt, ConfidenceBreakdown, TimingQuality } from '@huddle/core'; -import type { RuleConfig } from './contract.js'; +import type { InputRequirement, RuleConfig } from './contract.js'; -function worstTimingQuality(attempts: readonly Attempt[]): TimingQuality { +type TimingRequirement = Extract; + +function timingRequirement( + requiredInputs: readonly InputRequirement[] +): TimingRequirement | undefined { + return requiredInputs.find((input): input is TimingRequirement => input.startsWith('timing.')); +} + +function worstUsableTimingQuality( + attempts: readonly Attempt[], + requirement: TimingRequirement +): TimingQuality { const order: TimingQuality[] = ['engaged', 'wallclock', 'session_only', 'none']; - return attempts.reduce( + const usable = attempts.filter((attempt) => { + if (requirement === 'timing.engaged') + return attempt.timingQuality === 'engaged' && attempt.engagedMs != null; + if (requirement === 'timing.perAttempt') + return ( + (attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock') && + (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)) + ); + }); + return usable.reduce( (worst, attempt) => order.indexOf(attempt.timingQuality) > order.indexOf(worst) ? attempt.timingQuality : worst, 'engaged' @@ -15,38 +42,43 @@ export function isWinsorizedOut(attempt: Attempt): boolean { return attempt.timingWasWinsorized; } -function winsorizedCount(attempts: readonly Attempt[]): number { - return attempts.filter(isWinsorizedOut).length; +function winsorizationMultiplier( + attempts: readonly Attempt[], + requirement: TimingRequirement | undefined, + config: RuleConfig +): number { + if (!requirement || attempts.length === 0) return 1; + const fraction = attempts.filter(isWinsorizedOut).length / attempts.length; + if (fraction === 0) return 1; + return fraction <= config.confidence.winsorizedLowFraction + ? config.confidence.winsorizedLowMultiplier + : config.confidence.winsorizedHighMultiplier; } export function confidenceBreakdown( attempts: readonly Attempt[], - conflictingFamilyCount: number, + requiredInputs: readonly InputRequirement[], + hasConflict: boolean, config: RuleConfig ): ConfidenceBreakdown { - const timingMultiplier = - attempts.length === 0 ? 1 : config.confidence.tierFactors[worstTimingQuality(attempts)]; - const winsorizedPenalty = Math.min( - config.confidence.maximumWinsorizedPenalty, - winsorizedCount(attempts) * config.confidence.winsorizedPenaltyPerAttempt - ); + const requirement = timingRequirement(requiredInputs); + const timingMultiplier = requirement + ? config.confidence.tierFactors[worstUsableTimingQuality(attempts, requirement)] + : 1; return { timingMultiplier, - winsorizationMultiplier: 1 - winsorizedPenalty, - conflictMultiplier: Math.pow( - 1 - config.confidence.crossFamilyConflictPenalty, - conflictingFamilyCount - ), + winsorizationMultiplier: winsorizationMultiplier(attempts, requirement, config), + conflictMultiplier: hasConflict ? config.confidence.crossFamilyConflictMultiplier : 1, }; } -/** Apply the frozen timing-fidelity and winsorization attenuation uniformly. */ export function attenuateConfidence( baseConfidence: number, attempts: readonly Attempt[], - config: RuleConfig + config: RuleConfig, + requiredInputs: readonly InputRequirement[] = [] ): number { - const breakdown = confidenceBreakdown(attempts, 0, config); + const breakdown = confidenceBreakdown(attempts, requiredInputs, false, config); return Math.max( 0, Math.min(1, baseConfidence * breakdown.timingMultiplier * breakdown.winsorizationMultiplier) @@ -60,6 +92,6 @@ export function applyConflictPenalty( ): number { return Math.max( 0, - confidence * Math.pow(1 - config.confidence.crossFamilyConflictPenalty, conflictingFamilyCount) + confidence * (conflictingFamilyCount > 0 ? config.confidence.crossFamilyConflictMultiplier : 1) ); } diff --git a/packages/signal-engine/src/config/thresholds.ts b/packages/signal-engine/src/config/thresholds.ts index f544a73..05bbb50 100644 --- a/packages/signal-engine/src/config/thresholds.ts +++ b/packages/signal-engine/src/config/thresholds.ts @@ -51,16 +51,16 @@ export const RULE_CONFIG = { volumeDropFraction: 0.5, }, confidence: { - /** Timing uncertainty attenuates confidence uniformly in the engine. */ tierFactors: { engaged: 1, wallclock: 0.85, - session_only: 0.65, - none: 0.45, + session_only: 0.7, + none: 1, } satisfies Record, - winsorizedPenaltyPerAttempt: 0.02, - maximumWinsorizedPenalty: 0.5, - crossFamilyConflictPenalty: 0.25, + winsorizedLowFraction: 0.2, + winsorizedLowMultiplier: 0.9, + winsorizedHighMultiplier: 0.75, + crossFamilyConflictMultiplier: 0.7, /** Evidence counts at which each rule's confidence saturates. */ saturationCounts: { fastWrong: 3, @@ -151,9 +151,11 @@ export const PREREQ_UNKNOWN_IS_WEAK = RULE_CONFIG.prerequisite.unknownIsWeak; export const HINT_FARMING_FRACTION = RULE_CONFIG.engagement.hintFarmingFraction; export const VOLUME_DROP_FRACTION = RULE_CONFIG.engagement.volumeDropFraction; export const TIMING_TIER_CONFIDENCE_FACTORS = RULE_CONFIG.confidence.tierFactors; -export const WINSORIZED_PENALTY_PER_ATTEMPT = RULE_CONFIG.confidence.winsorizedPenaltyPerAttempt; -export const MAXIMUM_WINSORIZED_PENALTY = RULE_CONFIG.confidence.maximumWinsorizedPenalty; -export const CROSS_FAMILY_CONFLICT_PENALTY = RULE_CONFIG.confidence.crossFamilyConflictPenalty; +export const WINSORIZED_LOW_FRACTION = RULE_CONFIG.confidence.winsorizedLowFraction; +export const WINSORIZED_LOW_MULTIPLIER = RULE_CONFIG.confidence.winsorizedLowMultiplier; +export const WINSORIZED_HIGH_MULTIPLIER = RULE_CONFIG.confidence.winsorizedHighMultiplier; +export const CROSS_FAMILY_CONFLICT_MULTIPLIER = + RULE_CONFIG.confidence.crossFamilyConflictMultiplier; export const CONFIDENCE_SATURATION = RULE_CONFIG.confidence.saturationCounts; export const SESSION_AGGREGATE_CONFIDENCE_FACTOR = RULE_CONFIG.confidence.sessionAggregateFactor; export const WRONG_OF_LAST_N = RULE_CONFIG.evidence.wrongOfLastN; diff --git a/packages/signal-engine/src/contract.ts b/packages/signal-engine/src/contract.ts index cdbe5cc..1100cf5 100644 --- a/packages/signal-engine/src/contract.ts +++ b/packages/signal-engine/src/contract.ts @@ -90,7 +90,7 @@ export interface RuleContext { /** False means the attendance source did not respond; an empty array is known attendance. */ readonly attendanceLoaded: boolean; readonly config: RuleConfig; - readonly now: Date; + readonly asOf: Date; } type WidenRuleConfig = T extends number diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index e0ed997..577a708 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -3,25 +3,25 @@ import { timingProfileBoundsMs } from '@huddle/core'; import type { AbsenceSpan, Attempt, - EvidenceFamily, + ConfidenceBreakdown, + EvidenceBundle, + EvidenceScope, Item, - RootCause, Signal, Skill, SkillPrereq, } from '@huddle/core'; import { computePersonalBaseline } from './baselines/index.js'; -import { attenuateConfidence, confidenceBreakdown } from './confidence.js'; +import { confidenceBreakdown } from './confidence.js'; import type { RuleConfig, RuleContext, RuleOutcome, RuleContract, - Evidence, MasteryLookup, SkillGraphView, } from './contract.js'; -import { assembleEvidenceBundle, type Conflict, type EvidenceBundleContext } from './evidence.js'; +import { assembleEvidenceBundle, type Conflict } from './evidence.js'; import { ruleVersionForConfig, stableStringify } from './config/thresholds.js'; import { resolvedAnswerChoices } from './answer-choices.js'; import { isInHalfOpenWindow } from './windows.js'; @@ -60,6 +60,22 @@ export interface EngineOutput { classifications: StudentSkillClassification[]; } +interface PendingSignal { + ctx: RuleContext; + ruleOutcomes: RuleEvaluation[]; + ruleId: string; + rule: RuleContract; + outcome: Extract; + scope: EvidenceScope; +} + +interface ReconciledSignal extends PendingSignal { + id: number; + conflicts: PendingSignal[]; + breakdown: ConfidenceBreakdown; + finalConfidence: number; +} + export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutput { const itemLookup: RuleContext['items'] = {}; for (const item of input.items) { @@ -92,9 +108,9 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp const perSkillRules = rules.filter((rule) => rule.unit === 'student-skill'); const perStudentRules = rules.filter((rule) => rule.unit === 'student'); - const signals: EngineOutput['signals'] = []; const classifications: StudentSkillClassification[] = []; - let nextSignalId = 1; + const pendingSignals: PendingSignal[] = []; + const behaviorFingerprint = behaviorFingerprintForRules(input.config, rules); const sharedContext = { window: input.window, @@ -104,14 +120,13 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp attendance: input.attendance, attendanceLoaded: input.attendanceLoaded, config: input.config, - now: input.now, + asOf: input.window.end, }; const evaluateUnit = (ctx: RuleContext, activeRules: RuleContract[]) => { - const evaluated = evaluateContext(ctx, activeRules, nextSignalId); + const evaluated = evaluateContext(ctx, activeRules); classifications.push(evaluated.classification); - signals.push(...evaluated.signals); - nextSignalId += evaluated.signals.length; + pendingSignals.push(...evaluated.pendingSignals); }; if (perSkillRules.length > 0) { @@ -159,14 +174,100 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp } } + const reconciled = pendingSignals.map((pending, index) => { + const conflicts = pendingSignals.filter( + (other) => other !== pending && recordsConflict(pending, other) + ); + const breakdown = confidenceBreakdown( + pending.ctx.attempts, + pending.rule.requiredInputs, + conflicts.length > 0, + pending.ctx.config + ); + return { + ...pending, + id: index + 1, + conflicts, + breakdown, + finalConfidence: clampConfidence( + pending.outcome.confidence * + breakdown.timingMultiplier * + breakdown.winsorizationMultiplier * + breakdown.conflictMultiplier + ), + }; + }); + + const signals = reconciled.map((record) => { + const signal: Signal & { ruleId: string } = { + id: record.id, + signalIdentity: [ + record.ctx.student.id, + record.scope.kind === 'skill' ? record.scope.skill.code : 'cross-skill', + record.ruleId, + record.ctx.window.start.toISOString(), + record.ctx.window.end.toISOString(), + behaviorFingerprint, + ].join(':'), + studentId: record.ctx.student.id, + skillId: record.ctx.skill?.id ?? null, + kind: record.rule.emits, + severity: record.outcome.severity, + confidence: record.finalConfidence, + rawConfidence: record.outcome.confidence, + finalConfidence: record.finalConfidence, + confidenceBreakdown: record.breakdown, + scope: record.scope, + behaviorFingerprint, + evidenceFingerprint: undefined, + evidence: {}, + ruleVersion: ruleVersionForConfig(record.ctx.config), + windowStart: record.ctx.window.start, + windowEnd: record.ctx.window.end, + computedAt: input.now, + ruleId: record.ruleId, + }; + const additionalCauses = reconciled + .filter((other) => other.id !== record.id && other.ctx.student.id === record.ctx.student.id) + .sort(compareReconciledSignals) + .map((other) => ({ + signalId: other.id, + cause: other.rule.emits, + severity: other.outcome.severity, + finalConfidence: other.finalConfidence, + ruleId: other.ruleId, + scope: other.scope, + })); + const conflicts: Conflict[] = record.conflicts + .map((other) => ({ + family: other.rule.family, + suggestedCause: other.rule.emits, + ruleId: other.ruleId, + })) + .sort( + (a, b) => + a.family.localeCompare(b.family) || + a.suggestedCause.localeCompare(b.suggestedCause) || + a.ruleId.localeCompare(b.ruleId) + ); + signal.evidence = assembleEvidenceBundle(signal, { + ...record.ctx, + ruleOutcomes: record.ruleOutcomes, + ruleId: record.ruleId, + additionalCauses, + conflicts, + }); + signal.evidenceFingerprint = evidenceFingerprintForBundle(signal.evidence as EvidenceBundle); + return signal; + }); + return { signals, classifications }; } function evaluateContext( ctx: RuleContext, - rules: RuleContract[], - firstSignalId: number -): { classification: StudentSkillClassification; signals: (Signal & { ruleId: string })[] } { + rules: RuleContract[] +): { classification: StudentSkillClassification; pendingSignals: PendingSignal[] } { const ruleOutcomes: RuleEvaluation[] = []; for (const rule of rules) { const missing = findMissingInput(rule, ctx); @@ -203,119 +304,78 @@ function evaluateContext( : 'clear', outcomes: ruleOutcomes, }; - if (fired.length === 0) return { classification, signals: [] }; + if (fired.length === 0) return { classification, pendingSignals: [] }; - // Every fired rule receives a stable evidence-bearing signal before one is selected for ranking. - const scored = fired + const pendingSignals = fired .map((result) => ({ + ctx, + ruleOutcomes, ruleId: result.ruleId, rule: rules.find((rule) => rule.id === result.ruleId)!, outcome: result.outcome, - severity: result.outcome.severity, - relevant: relevantAttempts(result.ruleId, ctx.attempts), + scope: ctx.skill + ? { kind: 'skill' as const, skill: { code: ctx.skill.id, name: ctx.skill.name } } + : { kind: 'cross-skill' as const }, })) .sort((a, b) => a.ruleId.localeCompare(b.ruleId)); + return { classification, pendingSignals }; +} - const signals = scored.map((record, index) => { - const otherFamilies = new Set( - scored - .filter( - (other) => - other.ruleId !== record.ruleId && - other.rule.family !== record.rule.family && - other.rule.emits !== record.rule.emits - ) - .map((other) => other.rule.family) - ); - const breakdown = confidenceBreakdown(record.relevant, otherFamilies.size, ctx.config); - const finalConfidence = Math.max( - 0, - Math.min( - 1, - record.outcome.confidence * - breakdown.timingMultiplier * - breakdown.winsorizationMultiplier * - breakdown.conflictMultiplier - ) - ); - const scope = ctx.skill - ? { kind: 'skill' as const, skill: { code: ctx.skill.id, name: ctx.skill.name } } - : { kind: 'cross-skill' as const }; - const behaviorFingerprint = behaviorFingerprintForRules(ctx.config, rules); - const signal: Signal & { ruleId: string } = { - id: firstSignalId + index, - signalIdentity: [ - ctx.student.id, - scope.kind === 'skill' ? scope.skill.code : 'cross-skill', - record.ruleId, - ctx.window.start.toISOString(), - ctx.window.end.toISOString(), - behaviorFingerprint, - ].join(':'), - studentId: ctx.student.id, - skillId: ctx.skill?.id ?? null, - kind: record.rule.emits, - severity: record.severity, - confidence: finalConfidence, - rawConfidence: record.outcome.confidence, - finalConfidence, - confidenceBreakdown: breakdown, - scope, - behaviorFingerprint, - evidenceFingerprint: undefined, - evidence: {}, - ruleVersion: ruleVersionForConfig(ctx.config), - windowStart: ctx.window.start, - windowEnd: ctx.window.end, - computedAt: ctx.now, - ruleId: record.ruleId, - }; - const additionalCauses = scored - .filter((other) => other.ruleId !== record.ruleId) - .map((other) => ({ - signalId: - firstSignalId + scored.findIndex((candidate) => candidate.ruleId === other.ruleId), - cause: other.rule.emits, - severity: other.severity, - finalConfidence: Math.max( - 0, - Math.min(1, other.outcome.confidence * attenuateConfidence(1, other.relevant, ctx.config)) - ), - ruleId: other.ruleId, - scope: - other.rule.unit === 'student-skill' && ctx.skill - ? { kind: 'skill' as const, skill: { code: ctx.skill.id, name: ctx.skill.name } } - : { kind: 'cross-skill' as const }, - })) - .sort( - (a, b) => - b.severity - a.severity || - b.finalConfidence - a.finalConfidence || - a.ruleId.localeCompare(b.ruleId) - ); - const conflicts: Conflict[] = scored - .filter( - (other) => - other.ruleId !== record.ruleId && - other.rule.family !== record.rule.family && - other.rule.emits !== record.rule.emits - ) - .map((other) => ({ - family: other.rule.family, - suggestedCause: other.rule.emits, - ruleId: other.ruleId, - })); - signal.evidence = assembleEvidenceBundle(signal, { - ...ctx, - ruleOutcomes, - ruleId: record.ruleId, - additionalCauses, - conflicts, - }); - signal.evidenceFingerprint = JSON.stringify(signal.evidence); - return signal; - }); - return { classification, signals }; +function recordsConflict(a: PendingSignal, b: PendingSignal): boolean { + return ( + a.ctx.student.id === b.ctx.student.id && + a.rule.family !== b.rule.family && + a.rule.emits !== b.rule.emits && + scopesOverlap(a, b) + ); +} + +function scopesOverlap(a: PendingSignal, b: PendingSignal): boolean { + if (a.scope.kind === 'skill' && b.scope.kind === 'skill') + return a.scope.skill.code === b.scope.skill.code; + if (a.scope.kind === 'cross-skill' && b.scope.kind === 'cross-skill') return true; + const crossSkill = a.scope.kind === 'cross-skill' ? a : b; + const skill = + a.scope.kind === 'skill' + ? a.scope.skill.code + : b.scope.kind === 'skill' + ? b.scope.skill.code + : null; + if (skill == null) return false; + const contributingIds = new Set(crossSkill.outcome.evidence.attemptIds); + return crossSkill.ctx.attempts.some( + (attempt) => attempt.skillId === skill && contributingIds.has(attempt.id ?? 0) + ); +} + +function compareReconciledSignals(a: ReconciledSignal, b: ReconciledSignal): number { + return ( + b.outcome.severity - a.outcome.severity || + b.finalConfidence - a.finalConfidence || + a.ruleId.localeCompare(b.ruleId) || + compareSkillIds(a.ctx.skill?.id ?? null, b.ctx.skill?.id ?? null) || + a.ctx.student.id.localeCompare(b.ctx.student.id) + ); +} + +function compareSkillIds(a: string | null, b: string | null): number { + if (a === b) return 0; + if (a === null) return 1; + if (b === null) return -1; + return a.localeCompare(b); +} + +function clampConfidence(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function evidenceFingerprintForBundle(bundle: EvidenceBundle): string { + const canonicalEvidence = { + ...bundle, + attempts: bundle.attempts.map(({ attemptId: _attemptId, ...attempt }) => attempt), + additionalCauses: bundle.additionalCauses.map(({ signalId: _signalId, ...cause }) => cause), + }; + return createHash('sha256').update(stableStringify(canonicalEvidence)).digest('hex'); } function behaviorFingerprintForRules(config: RuleConfig, rules: readonly RuleContract[]): string { @@ -398,34 +458,19 @@ function inputSatisfied( case 'skillMastery': { if (ctx.skill == null) return false; const before = ctx.mastery.at(ctx.skill.id, ctx.window.start); - const now = ctx.mastery.at(ctx.skill.id, ctx.now); - return before.isKnown && now.isKnown; + const current = ctx.mastery.at(ctx.skill.id, ctx.asOf); + return before.isKnown && current.isKnown; } case 'prerequisiteMastery': if (ctx.skill == null) return false; return ctx.skillGraph .prerequisites(ctx.skill.id) - .every((edge) => ctx.mastery.at(edge.prereqId, ctx.now).isKnown); + .every((edge) => ctx.mastery.at(edge.prereqId, ctx.asOf).isKnown); case 'attendance': return ctx.attendanceLoaded; } } -function relevantAttempts(ruleId: string, attempts: readonly Attempt[]): readonly Attempt[] { - switch (ruleId) { - case 'grinding.slow-session-vs-baseline': - return attempts.filter((attempt) => attempt.timingQuality !== 'none'); - case 'guessing.fast-wrong-vs-baseline': - case 'grinding.slow-wrong-vs-baseline': - case 'retry.no-read': - return attempts.filter( - (attempt) => attempt.timingQuality === 'engaged' || attempt.timingQuality === 'wallclock' - ); - default: - return attempts; - } -} - /** * A partial recorded absence reduces the expected volume by absent days. If the * observed work meets that attended-day expectation, the absence explains the drop. diff --git a/packages/signal-engine/src/index.ts b/packages/signal-engine/src/index.ts index a7d76c6..daa4f79 100644 --- a/packages/signal-engine/src/index.ts +++ b/packages/signal-engine/src/index.ts @@ -6,7 +6,7 @@ export { computePersonalBaseline } from './baselines/index.js'; export { assembleEvidenceBundle, type EvidenceBundle } from './evidence.js'; export { attenuateConfidence, applyConflictPenalty } from './confidence.js'; export { severity, URGENCY } from './severity.js'; -export { boardWindows } from './windows.js'; +export { BOARD_TIMEZONE, boardWindows } from './windows.js'; export { RULE_VERSION } from './config/thresholds.js'; export type { RuleContract, diff --git a/packages/signal-engine/src/rules/decay.ts b/packages/signal-engine/src/rules/decay.ts index c5fce55..5efeaf5 100644 --- a/packages/signal-engine/src/rules/decay.ts +++ b/packages/signal-engine/src/rules/decay.ts @@ -15,7 +15,7 @@ export const contract: RuleContract = { return { type: 'abstained', reason: { kind: 'missing-input', input: 'skillMastery' } }; } const m1 = ctx.mastery.at(ctx.skill.id, ctx.window.start); - const m2 = ctx.mastery.at(ctx.skill.id, ctx.now); + const m2 = ctx.mastery.at(ctx.skill.id, ctx.asOf); if (!m1.isKnown || !m2.isKnown) { return { diff --git a/packages/signal-engine/src/rules/grinding-session.ts b/packages/signal-engine/src/rules/grinding-session.ts index e31c726..162d433 100644 --- a/packages/signal-engine/src/rules/grinding-session.ts +++ b/packages/signal-engine/src/rules/grinding-session.ts @@ -46,7 +46,7 @@ export const contract: RuleContract = { family: 'timing', urgency: URGENCY.grinding, requiredInputs: ['timing.sessionAggregate', 'personalBaseline'], - unit: 'student-skill', + unit: 'student', evaluate(ctx: RuleContext): RuleOutcome { const baselineMs = ctx.baseline?.sessionMeanPaceMs; if (baselineMs == null || baselineMs <= 0) { diff --git a/packages/signal-engine/src/rules/prereq-blocked.ts b/packages/signal-engine/src/rules/prereq-blocked.ts index 487297a..aaaf483 100644 --- a/packages/signal-engine/src/rules/prereq-blocked.ts +++ b/packages/signal-engine/src/rules/prereq-blocked.ts @@ -24,7 +24,7 @@ export const contract: RuleContract = { for (const p of prereqs) { totalStrength += p.strength; - const m = ctx.mastery.at(p.prereqId, ctx.now); + const m = ctx.mastery.at(p.prereqId, ctx.asOf); const weak = !m.isKnown ? ctx.config.prerequisite.unknownIsWeak : m.value! < ctx.config.prerequisite.weakMasteryThreshold; diff --git a/packages/signal-engine/test/confidence-conflict.test.ts b/packages/signal-engine/test/confidence-conflict.test.ts new file mode 100644 index 0000000..a1e2010 --- /dev/null +++ b/packages/signal-engine/test/confidence-conflict.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +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'; + +function breakdown( + requiredInputs: readonly InputRequirement[], + attempts = [attempt({ id: 1 })], + hasConflict = false +) { + return confidenceBreakdown(attempts, requiredInputs, hasConflict, RULE_CONFIG); +} + +describe('frozen confidence policy', () => { + it('does not attenuate timing-free rules when timing is absent', () => { + const result = breakdown( + ['answerChoices'], + [ + attempt({ + id: 1, + timingQuality: 'none', + elapsedMs: null, + engagedMs: null, + timingWasWinsorized: true, + }), + ] + ); + expect(result).toEqual({ + timingMultiplier: 1, + winsorizationMultiplier: 1, + conflictMultiplier: 1, + }); + }); + + it('uses the declared timing matrix and winsorization fraction breakpoints', () => { + const attempts = Array.from({ length: 5 }, (_, index) => + attempt({ + id: index + 1, + timingQuality: index === 0 ? 'none' : 'wallclock', + elapsedMs: index === 0 ? null : 60_000, + engagedMs: null, + timingWasWinsorized: index === 0, + }) + ); + expect(breakdown(['timing.perAttempt'], attempts)).toMatchObject({ + timingMultiplier: 0.85, + winsorizationMultiplier: 0.9, + }); + attempts[1] = { + ...attempts[1]!, + timingQuality: 'none', + elapsedMs: null, + timingWasWinsorized: true, + }; + expect(breakdown(['timing.perAttempt'], attempts)).toMatchObject({ + timingMultiplier: 0.85, + winsorizationMultiplier: 0.75, + }); + expect( + breakdown( + ['timing.sessionAggregate'], + [ + attempt({ + id: 9, + timingQuality: 'session_only', + elapsedMs: null, + engagedMs: null, + sessionTotalMs: 180_000, + }), + ] + ).timingMultiplier + ).toBe(0.7); + }); + + it('applies the cross-family multiplier once', () => { + 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/grinding-session-engine.test.ts b/packages/signal-engine/test/grinding-session-engine.test.ts index ddfdebd..c182530 100644 --- a/packages/signal-engine/test/grinding-session-engine.test.ts +++ b/packages/signal-engine/test/grinding-session-engine.test.ts @@ -80,17 +80,17 @@ function runSessionScenario(sessionTotalMs: number | null) { describe('grinding.slow-session-vs-baseline through the engine', () => { it('fires for slow session_only totals compared with the personal session baseline', () => { const output = runSessionScenario(540_000); - const sessionOutcome = output.classifications[0]?.outcomes.find( - (outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline' - )?.outcome; + const sessionOutcome = output.classifications + .find((classification) => classification.skillId === null) + ?.outcomes.find((outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline')?.outcome; expect(sessionOutcome?.type).toBe('fired'); }); it('abstains instead of fabricating a signal when session totals are unavailable', () => { const output = runSessionScenario(null); - const sessionOutcome = output.classifications[0]?.outcomes.find( - (outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline' - )?.outcome; + const sessionOutcome = output.classifications + .find((classification) => classification.skillId === null) + ?.outcomes.find((outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline')?.outcome; expect(sessionOutcome).toEqual({ type: 'abstained', reason: { kind: 'missing-input', input: 'timing.sessionAggregate' }, @@ -140,7 +140,7 @@ describe('grinding.slow-session-vs-baseline through the engine', () => { allRules ); const targetClassification = output.classifications.find( - (classification) => classification.skillId === 'TEKS.4.2A' + (classification) => classification.skillId === null ); const sessionOutcome = targetClassification?.outcomes.find( (outcome) => outcome.ruleId === 'grinding.slow-session-vs-baseline' diff --git a/packages/signal-engine/test/half-open-engine.test.ts b/packages/signal-engine/test/half-open-engine.test.ts index a6a364f..65b8910 100644 --- a/packages/signal-engine/test/half-open-engine.test.ts +++ b/packages/signal-engine/test/half-open-engine.test.ts @@ -10,7 +10,7 @@ const countRule: RuleContract = { family: 'timing', urgency: 1, unit: 'student-skill', - requiredInputs: [], + requiredInputs: ['skillMastery'], evaluate: (ctx) => ctx.attempts.length ? { @@ -37,8 +37,9 @@ describe('half-open board/mastery boundary contract', () => { attempt({ id: 3, submittedAt: end }), ], window: { start, end }, - now: end, + asOf: end, }); + const masteryAnchors: string[] = []; const output = runEngine( { students: [context.student], @@ -46,11 +47,16 @@ describe('half-open board/mastery boundary contract', () => { attempts: [...context.attempts], items: Object.values(defaultItems), skillPrereqs: [], - mastery: context.mastery, + mastery: { + at: (_skillId, anchor) => { + masteryAnchors.push(anchor.toISOString()); + return { value: 0.8, isKnown: true as const }; + }, + }, attendance: [], attendanceLoaded: true, config: context.config, - now: end, + now: new Date('2026-03-09T18:00:00.000Z'), window: { start, end }, }, [countRule] @@ -58,5 +64,6 @@ describe('half-open board/mastery boundary contract', () => { const bundle = output.signals[0]!.evidence as import('@huddle/core').EvidenceBundle; expect(bundle.attempts.map((entry) => entry.attemptId)).toEqual([1, 2]); expect(bundle.attempts.map((entry) => entry.attemptId)).not.toContain(3); + expect(new Set(masteryAnchors)).toEqual(new Set([start.toISOString(), end.toISOString()])); }); }); 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 a31b60d..3efb30e 100644 --- a/packages/signal-engine/test/per-fired-rule-evidence.test.ts +++ b/packages/signal-engine/test/per-fired-rule-evidence.test.ts @@ -32,7 +32,7 @@ describe('per-fired-rule evidence identity', () => { attendance: [], attendanceLoaded: true, config: context.config, - now: context.now, + now: context.asOf, window: context.window, }, rules @@ -59,4 +59,102 @@ describe('per-fired-rule evidence identity', () => { ).attempts ).toHaveLength(1); }); + + it('reconciles overlapping skill and cross-skill signals before hashing evidence', () => { + const rules: RuleContract[] = [ + { + id: 'guessing.skill', + version: 'v1', + emits: 'guessing', + family: 'answer-choice', + urgency: 0.8, + unit: 'student-skill', + requiredInputs: [], + evaluate: (ctx) => ({ + type: 'fired', + severity: 0.8, + confidence: 0.9, + evidence: { + attemptIds: ctx.attempts.map((entry) => entry.id ?? 0), + summary: 'skill', + values: {}, + }, + }), + }, + { + id: 'engagement.cross', + version: 'v1', + emits: 'disengagement', + family: 'engagement', + urgency: 0.8, + unit: 'student', + requiredInputs: [], + evaluate: (ctx) => ({ + type: 'fired', + severity: 0.7, + confidence: 0.8, + evidence: { + attemptIds: ctx.attempts.map((entry) => entry.id ?? 0), + summary: 'cross', + values: {}, + }, + }), + }, + ]; + const run = (attemptId: number, activeRules = rules) => { + const context = makeContext({ attempts: [attempt({ id: attemptId })] }); + return runEngine( + { + students: [context.student], + skills: [defaultSkill], + attempts: [...context.attempts], + items: Object.values(defaultItems), + skillPrereqs: [], + mastery: context.mastery, + attendance: [], + attendanceLoaded: true, + config: context.config, + now: context.asOf, + window: context.window, + }, + activeRules + ); + }; + + const first = run(1); + const skill = first.signals.find((signal) => signal.ruleId === 'guessing.skill')!; + const cross = first.signals.find((signal) => signal.ruleId === 'engagement.cross')!; + expect(skill.confidenceBreakdown?.conflictMultiplier).toBe(0.7); + expect(cross.confidenceBreakdown?.conflictMultiplier).toBe(0.7); + expect(skill.finalConfidence).toBeCloseTo(0.63); + expect(cross.finalConfidence).toBeCloseTo(0.56); + expect((skill.evidence as import('@huddle/core').EvidenceBundle).conflicts).toMatchObject([ + { ruleId: 'engagement.cross', suggestedCause: 'disengagement' }, + ]); + expect( + (skill.evidence as import('@huddle/core').EvidenceBundle).additionalCauses[0] + ?.finalConfidence + ).toBeCloseTo(cross.finalConfidence!); + expect(new Set(first.signals.map((signal) => signal.behaviorFingerprint)).size).toBe(1); + expect( + first.signals.every((signal) => signal.evidenceFingerprint?.match(/^[a-f0-9]{64}$/)) + ).toBe(true); + + const rekeyed = run(99); + for (const signal of first.signals) { + expect( + rekeyed.signals.find((candidate) => candidate.ruleId === signal.ruleId)?.evidenceFingerprint + ).toBe(signal.evidenceFingerprint); + } + + const changedBehavior = run(1, [ + rules[0]!, + { ...rules[1]!, version: 'v2' }, + ]); + expect( + changedBehavior.signals.every( + (signal) => signal.behaviorFingerprint !== skill.behaviorFingerprint + ) + ).toBe(true); + }); }); diff --git a/packages/signal-engine/test/rule-context.ts b/packages/signal-engine/test/rule-context.ts index f9e5969..74c7881 100644 --- a/packages/signal-engine/test/rule-context.ts +++ b/packages/signal-engine/test/rule-context.ts @@ -40,8 +40,8 @@ export const easyItem: Item = { }; export function makeContext(overrides: Partial = {}): RuleContext { - const now = overrides.now ?? new Date('2026-07-28T00:00:00Z'); - const window = overrides.window ?? { start: new Date('2026-07-21T00:00:00Z'), end: now }; + const asOf = overrides.asOf ?? new Date('2026-07-28T00:00:00Z'); + const window = overrides.window ?? { start: new Date('2026-07-21T00:00:00Z'), end: asOf }; const attempts = overrides.attempts ?? []; return { student: { id: '11111111-1111-1111-1111-111111111111', firstName: 'Alex' }, @@ -62,7 +62,7 @@ export function makeContext(overrides: Partial = {}): RuleContext { attendance: [], attendanceLoaded: true, config: RULE_CONFIG, - now, + asOf, ...overrides, }; } diff --git a/packages/signal-engine/test/rules.test.ts b/packages/signal-engine/test/rules.test.ts index 0f72ddf..042ad11 100644 --- a/packages/signal-engine/test/rules.test.ts +++ b/packages/signal-engine/test/rules.test.ts @@ -538,7 +538,7 @@ describe('Rule: decay.previously-mastered', () => { }; const ctx = makeContext({ window, - now: window.end, + asOf: window.end, mastery: { at: (skillId: string, anchor: Date) => { if (anchor.getTime() === window.start.getTime()) diff --git a/scripts/nightly.ts b/scripts/nightly.ts index 6ecfd13..9e26279 100644 --- a/scripts/nightly.ts +++ b/scripts/nightly.ts @@ -17,6 +17,7 @@ import type { } from '@huddle/core'; import { allRules, + BOARD_TIMEZONE, boardWindows, rankBySeverity, rankedEvidenceBundle, @@ -56,11 +57,16 @@ function parseArgs() { } function todayISO() { - return new Date().toISOString().slice(0, 10); -} - -function endOfDayUTC(dateStr: string) { - return new Date(`${dateStr}T23:59:59.999Z`); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: BOARD_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date()); + const values = Object.fromEntries( + parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]) + ); + return `${values.year}-${values.month}-${values.day}`; } async function loadGuide(client: PoolClient, guideIdArg?: string) { @@ -205,7 +211,7 @@ async function loadAttempts( FROM attempt WHERE student_id = ANY($1::uuid[]) AND submitted_at >= $2 - AND submitted_at <= $3 + AND submitted_at < $3 ORDER BY submitted_at, id`, [studentIds, windowStart, windowEnd] ); @@ -306,8 +312,11 @@ async function main() { const guideIdArg = values['--guide-id'] ?? process.env.GUIDE_ID; const useBatch = flags.has('--batch'); - const anchor = endOfDayUTC(boardDate); - const { windowStart, historyStart } = boardWindows(anchor, RULE_CONFIG); + const { windowStart, historyStart, windowEnd } = boardWindows( + new Date(`${boardDate}T12:00:00.000Z`), + RULE_CONFIG + ); + const computedAt = new Date(); const client = await pool.connect(); try { @@ -335,7 +344,7 @@ async function main() { const studentIds = students.map((s) => s.id); const absences = await loadAbsences(client, studentIds); - const nowRows = await getMasteryRowsForGuide(client, guide.id, anchor); + const nowRows = await getMasteryRowsForGuide(client, guide.id, windowEnd); const startRows = await getMasteryRowsForGuide(client, guide.id, windowStart); await clearMasterySnapshotForGuide(client, boardDate, guide.id); @@ -360,8 +369,8 @@ async function main() { ); } - const allAttempts = await loadAttempts(client, studentIds, historyStart, anchor); - const masteryByAnchor = buildMasteryByAnchor(nowRows, startRows, anchor, windowStart); + const allAttempts = await loadAttempts(client, studentIds, historyStart, windowEnd); + const masteryByAnchor = buildMasteryByAnchor(nowRows, startRows, windowEnd, windowStart); const computedSignals: Signal[] = []; for (const student of students) { @@ -378,8 +387,8 @@ async function main() { attendance: absences, attendanceLoaded: true, config: RULE_CONFIG, - now: anchor, - window: { start: windowStart, end: anchor }, + now: computedAt, + window: { start: windowStart, end: windowEnd }, }; const output = runEngine(input, allRules); computedSignals.push(...output.signals); From 926bd3d1235a0878b6568b86dbf639d07934f943 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 13:06:07 -0500 Subject: [PATCH 3/7] no-mistakes(review): Align evidence identity, metadata, calendar, and portfolio gates --- packages/core/src/types.ts | 4 +- packages/ingest/src/winsorize.ts | 4 +- packages/ingest/test/winsorize.test.ts | 5 + .../narrator/test/request-contract.test.ts | 2 + packages/signal-engine/src/contract.ts | 11 +- packages/signal-engine/src/engine.ts | 35 +++--- packages/signal-engine/src/evidence.ts | 24 ++-- packages/signal-engine/src/index.ts | 7 +- packages/signal-engine/src/rules/decay.ts | 2 +- .../signal-engine/src/rules/disengagement.ts | 2 +- .../src/rules/grinding-repeated.ts | 4 +- .../src/rules/grinding-session.ts | 4 +- .../signal-engine/src/rules/grinding-slow.ts | 2 +- .../src/rules/guessing-cycling.ts | 4 +- .../src/rules/guessing-fast-wrong.ts | 2 +- .../src/rules/guessing-inversion.ts | 4 +- .../src/rules/guessing-scattered.ts | 2 +- .../signal-engine/src/rules/hint-farming.ts | 2 +- .../signal-engine/src/rules/prereq-blocked.ts | 2 +- .../src/rules/prereq-misconception.ts | 2 +- .../src/rules/retry-identical.ts | 10 +- .../signal-engine/src/rules/retry-no-read.ts | 2 +- packages/signal-engine/src/windows.ts | 17 +++ .../test/attendance-engine.test.ts | 17 ++- .../signal-engine/test/board-window.test.ts | 15 ++- .../signal-engine/test/determinism.test.ts | 2 +- .../test/half-open-engine.test.ts | 2 +- .../test/per-fired-rule-evidence.test.ts | 103 ++++++++++++++++-- packages/signal-engine/test/rule-context.ts | 2 +- scripts/nightly.ts | 14 ++- .../compliance-report.md | 2 +- .../contracts/eval-harness.md | 12 +- .../contracts/evidence-bundle.md | 4 +- 33 files changed, 249 insertions(+), 77 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 99b4c56..a979a72 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -216,10 +216,12 @@ export interface EvidenceBundle { localDays: number; }; attempts: Array<{ - attemptId: number; + attemptId: number | null; + sourceEventId: string; ordinal: number; skill: { code: string; name: string }; itemType: ItemType; + timingProfile: TimingProfile; submittedAt: string; isCorrect: boolean; elapsedMs: number | null; diff --git a/packages/ingest/src/winsorize.ts b/packages/ingest/src/winsorize.ts index 59e09b9..31f7b93 100644 --- a/packages/ingest/src/winsorize.ts +++ b/packages/ingest/src/winsorize.ts @@ -14,9 +14,11 @@ export interface WinsorizedAttempt extends NormalizedAttempt { */ export function winsorize( attempt: NormalizedAttempt, - timingProfile: TimingProfile = 'standard_multiple_choice' + timingProfile: TimingProfile ): WinsorizedAttempt { + if (timingProfile == null) throw new Error('A resolved timing profile is required'); const bound = winsorizationBound(timingProfile); + if (bound == null) throw new Error(`Unknown timing profile: ${timingProfile}`); const elapsedMs = attempt.elapsedMs != null && attempt.elapsedMs > bound ? null : attempt.elapsedMs; const engagedMs = diff --git a/packages/ingest/test/winsorize.test.ts b/packages/ingest/test/winsorize.test.ts index 2d45f0a..263b97e 100644 --- a/packages/ingest/test/winsorize.test.ts +++ b/packages/ingest/test/winsorize.test.ts @@ -51,4 +51,9 @@ describe('winsorize', () => { expect(result.engagedMs).toBeNull(); expect(result.timingQuality).toBe('wallclock'); }); + it('rejects an unresolved timing profile', () => { + expect(() => winsorize(attempt(), undefined as never)).toThrow( + 'A resolved timing profile is required' + ); + }); }); diff --git a/packages/narrator/test/request-contract.test.ts b/packages/narrator/test/request-contract.test.ts index f6b8294..a150047 100644 --- a/packages/narrator/test/request-contract.test.ts +++ b/packages/narrator/test/request-contract.test.ts @@ -32,9 +32,11 @@ const bundle: EvidenceBundle = { attempts: [ { attemptId: 1, + sourceEventId: 'event-1', ordinal: 1, skill: { code: 'TEKS.4.3A', name: 'Unit fractions' }, itemType: 'multiple_choice', + timingProfile: 'standard_multiple_choice', submittedAt: '2026-07-27', isCorrect: false, elapsedMs: 1000, diff --git a/packages/signal-engine/src/contract.ts b/packages/signal-engine/src/contract.ts index 1100cf5..9688c2e 100644 --- a/packages/signal-engine/src/contract.ts +++ b/packages/signal-engine/src/contract.ts @@ -3,11 +3,13 @@ import type { Attempt, RootCause, EvidenceFamily, + ItemType, Skill, SkillPrereq, MasterySnapshot, PersonalBaseline, AbsenceSpan, + TimingProfile, } from '@huddle/core'; export type InputRequirement = @@ -43,7 +45,7 @@ export interface RuleContract { } export interface Evidence { - attemptIds: number[]; + attemptSourceEventIds: string[]; summary: string; values: Record; } @@ -81,7 +83,12 @@ export interface RuleContext { readonly sessionAttempts: readonly Attempt[]; readonly items: Record< string, - { difficulty: number; choices: { key: string; isCorrect: boolean; misconceptionId?: string }[] } + { + difficulty: number; + choices: { key: string; isCorrect: boolean; misconceptionId?: string }[]; + itemType: ItemType; + timingProfile: TimingProfile; + } >; readonly baseline: PersonalBaseline | null; readonly skillGraph: SkillGraphView; diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index 577a708..7d0e447 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -22,9 +22,13 @@ import type { SkillGraphView, } from './contract.js'; import { assembleEvidenceBundle, type Conflict } from './evidence.js'; -import { ruleVersionForConfig, stableStringify } from './config/thresholds.js'; +import { stableStringify } from './config/thresholds.js'; import { resolvedAnswerChoices } from './answer-choices.js'; -import { isInHalfOpenWindow } from './windows.js'; +import { + chicagoCalendarDaysBetween, + chicagoDateOnlyBoundary, + isInHalfOpenWindow, +} from './windows.js'; export interface EngineInput { students: { id: string; firstName: string }[]; @@ -79,7 +83,12 @@ interface ReconciledSignal extends PendingSignal { export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutput { const itemLookup: RuleContext['items'] = {}; for (const item of input.items) { - itemLookup[item.id] = { difficulty: item.difficulty, choices: item.choices }; + itemLookup[item.id] = { + difficulty: item.difficulty, + choices: item.choices, + itemType: item.itemType, + timingProfile: item.timingProfile, + }; } const skillGraph = buildSkillGraph(input.skills, input.skillPrereqs); @@ -221,7 +230,7 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp behaviorFingerprint, evidenceFingerprint: undefined, evidence: {}, - ruleVersion: ruleVersionForConfig(record.ctx.config), + ruleVersion: record.rule.version, windowStart: record.ctx.window.start, windowEnd: record.ctx.window.end, computedAt: input.now, @@ -342,9 +351,9 @@ function scopesOverlap(a: PendingSignal, b: PendingSignal): boolean { ? b.scope.skill.code : null; if (skill == null) return false; - const contributingIds = new Set(crossSkill.outcome.evidence.attemptIds); + const contributingIds = new Set(crossSkill.outcome.evidence.attemptSourceEventIds); return crossSkill.ctx.attempts.some( - (attempt) => attempt.skillId === skill && contributingIds.has(attempt.id ?? 0) + (attempt) => attempt.skillId === skill && contributingIds.has(attempt.sourceEventId) ); } @@ -482,29 +491,29 @@ function absenceExplainsDrop(ctx: RuleContext): AbsenceSpan | null { const intervals = ctx.attendance .filter((span) => span.studentId === ctx.student.id) .map((span) => ({ - start: Math.max(windowStart, new Date(span.startDate).getTime()), - end: Math.min(windowEnd, new Date(span.endDate).getTime() + 24 * 60 * 60 * 1000), + start: Math.max(windowStart, chicagoDateOnlyBoundary(span.startDate).getTime()), + end: Math.min(windowEnd, chicagoDateOnlyBoundary(span.endDate, 1).getTime()), span, })) .filter((interval) => interval.end > interval.start) .sort((a, b) => a.start - b.start); if (intervals.length === 0) return null; - let absentMs = 0; + let absentDays = 0; let mergedStart = intervals[0]!.start; let mergedEnd = intervals[0]!.end; for (const interval of intervals.slice(1)) { if (interval.start <= mergedEnd) mergedEnd = Math.max(mergedEnd, interval.end); else { - absentMs += mergedEnd - mergedStart; + absentDays += chicagoCalendarDaysBetween(new Date(mergedStart), new Date(mergedEnd)); mergedStart = interval.start; mergedEnd = interval.end; } } - absentMs += mergedEnd - mergedStart; + absentDays += chicagoCalendarDaysBetween(new Date(mergedStart), new Date(mergedEnd)); - const windowMs = Math.max(1, windowEnd - windowStart); - const attendedDays = Math.max(0, (windowMs - absentMs) / (24 * 60 * 60 * 1000)); + const windowDays = Math.max(1, chicagoCalendarDaysBetween(ctx.window.start, ctx.window.end)); + const attendedDays = Math.max(0, windowDays - absentDays); const expectedAttendedVolume = ctx.baseline.volumePerDay * attendedDays; const minimumExpectedWork = expectedAttendedVolume * (1 - ctx.config.engagement.volumeDropFraction); diff --git a/packages/signal-engine/src/evidence.ts b/packages/signal-engine/src/evidence.ts index 2f2256d..a821666 100644 --- a/packages/signal-engine/src/evidence.ts +++ b/packages/signal-engine/src/evidence.ts @@ -2,7 +2,6 @@ import type { EvidenceBundle, EvidenceFamily, EvidenceScope, - ItemType, RootCause, Signal, } from '@huddle/core'; @@ -10,6 +9,7 @@ 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 { chicagoCalendarDaysBetween } from './windows.js'; export interface Conflict { family: EvidenceFamily; @@ -35,10 +35,14 @@ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContex const firedOutcome = ctx.ruleOutcomes.find((result) => result.ruleId === ctx.ruleId)?.outcome; if (firedOutcome?.type !== 'fired') throw new Error(`Fired rule ${ctx.ruleId} has no evidence`); const firedEvidence = firedOutcome.evidence; - const contributingIds = new Set(firedEvidence.attemptIds); + const contributingIds = new Set(firedEvidence.attemptSourceEventIds); const attempts = ctx.attempts - .filter((attempt) => contributingIds.has(attempt.id ?? 0)) - .sort((a, b) => a.submittedAt.getTime() - b.submittedAt.getTime() || (a.id ?? 0) - (b.id ?? 0)); + .filter((attempt) => contributingIds.has(attempt.sourceEventId)) + .sort( + (a, b) => + a.submittedAt.getTime() - b.submittedAt.getTime() || + a.sourceEventId.localeCompare(b.sourceEventId) + ); const wrongAttempts = attempts.filter((attempt) => !attempt.isCorrect); const wrongDurations = wrongAttempts .map(perAttemptDurationMs) @@ -55,15 +59,17 @@ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContex consecutiveWrong++; const bundleAttempts = attempts.map((attempt, index) => { const item = ctx.items[attempt.itemId]; + if (!item) throw new Error(`Missing resolved item metadata for ${attempt.itemId}`); const chosen = item?.choices.find((choice) => choice.key === attempt.answerGiven?.key); return { - attemptId: attempt.id ?? 0, + attemptId: attempt.id, + sourceEventId: attempt.sourceEventId, ordinal: index + 1, skill: ctx.skill ? { code: ctx.skill.id, name: ctx.skill.name } : { code: attempt.skillId, name: attempt.skillId }, - // The engine receives resolved item metadata from the compiler; legacy in-memory fixtures are MCQ. - itemType: 'multiple_choice' as ItemType, + itemType: item.itemType, + timingProfile: item.timingProfile, submittedAt: attempt.submittedAt.toISOString(), isCorrect: attempt.isCorrect, elapsedMs: attempt.elapsedMs, @@ -124,9 +130,7 @@ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContex start: signal.windowStart.toISOString(), end: signal.windowEnd.toISOString(), asOf: signal.windowEnd.toISOString(), - localDays: Math.round( - (signal.windowEnd.getTime() - signal.windowStart.getTime()) / MS_PER_DAY - ), + localDays: chicagoCalendarDaysBetween(signal.windowStart, signal.windowEnd), }, attempts: bundleAttempts, sessions: [...sessionRows.values()].sort((a, b) => a.sessionId - b.sessionId), diff --git a/packages/signal-engine/src/index.ts b/packages/signal-engine/src/index.ts index daa4f79..1ce537d 100644 --- a/packages/signal-engine/src/index.ts +++ b/packages/signal-engine/src/index.ts @@ -6,7 +6,12 @@ export { computePersonalBaseline } from './baselines/index.js'; export { assembleEvidenceBundle, type EvidenceBundle } from './evidence.js'; export { attenuateConfidence, applyConflictPenalty } from './confidence.js'; export { severity, URGENCY } from './severity.js'; -export { BOARD_TIMEZONE, boardWindows } from './windows.js'; +export { + BOARD_TIMEZONE, + boardWindows, + chicagoCalendarDaysBetween, + chicagoDateOnlyBoundary, +} from './windows.js'; export { RULE_VERSION } from './config/thresholds.js'; export type { RuleContract, diff --git a/packages/signal-engine/src/rules/decay.ts b/packages/signal-engine/src/rules/decay.ts index 5efeaf5..4a5e37a 100644 --- a/packages/signal-engine/src/rules/decay.ts +++ b/packages/signal-engine/src/rules/decay.ts @@ -41,7 +41,7 @@ export const contract: RuleContract = { severity: severity('decay', intensity, ctx.config), confidence: Math.min(1, drop * ctx.config.confidence.saturationCounts.decayMultiplier), evidence: { - attemptIds: ctx.attempts.map((a) => a.id ?? 0), + attemptSourceEventIds: ctx.attempts.map((attempt) => attempt.sourceEventId), summary: `Mastery dropped from ${previous.toFixed(2)} to ${current.toFixed(2)}`, values: { previous, diff --git a/packages/signal-engine/src/rules/disengagement.ts b/packages/signal-engine/src/rules/disengagement.ts index d3dd0aa..8afa35f 100644 --- a/packages/signal-engine/src/rules/disengagement.ts +++ b/packages/signal-engine/src/rules/disengagement.ts @@ -36,7 +36,7 @@ export const contract: RuleContract = { expectedVolume / ctx.config.confidence.saturationCounts.disengagementBaselineVolume ), evidence: { - attemptIds: ctx.attempts.map((attempt) => attempt.id ?? 0), + attemptSourceEventIds: ctx.attempts.map((attempt) => attempt.sourceEventId), summary: `Attempt volume dropped from expected ${expectedVolume.toFixed(1)} to ${currentVolume}`, values: { currentVolume, expectedVolume, dropFraction }, }, diff --git a/packages/signal-engine/src/rules/grinding-repeated.ts b/packages/signal-engine/src/rules/grinding-repeated.ts index 5a4a575..d6fb974 100644 --- a/packages/signal-engine/src/rules/grinding-repeated.ts +++ b/packages/signal-engine/src/rules/grinding-repeated.ts @@ -9,7 +9,7 @@ function repeatedWrongNoHelp(itemAttempts: Attempt[], ctx: RuleContext): Attempt (a, b) => a.attemptIndex - b.attemptIndex || a.submittedAt.getTime() - b.submittedAt.getTime() || - (a.id ?? 0) - (b.id ?? 0) + a.sourceEventId.localeCompare(b.sourceEventId) ); if (ordered.length < 2 || !ordered.some((attempt) => attempt.attemptIndex >= 2)) return []; if (ordered.some((attempt) => attempt.isCorrect || attempt.hintsUsed > 0)) return []; @@ -60,7 +60,7 @@ export const contract: RuleContract = { matched.length / ctx.config.confidence.saturationCounts.repeatedNoHelp ), evidence: { - attemptIds: matched.map((a) => a.id ?? 0), + attemptSourceEventIds: matched.map((attempt) => attempt.sourceEventId), summary: `${matched.length} repeated attempts followed entirely unsuccessful, hint-free item sequences`, values: { repeatedWrongNoHelpCount: matched.length, diff --git a/packages/signal-engine/src/rules/grinding-session.ts b/packages/signal-engine/src/rules/grinding-session.ts index 162d433..bdd3db1 100644 --- a/packages/signal-engine/src/rules/grinding-session.ts +++ b/packages/signal-engine/src/rules/grinding-session.ts @@ -87,9 +87,9 @@ export const contract: RuleContract = { Math.min(1, wrongInSlow / ctx.config.confidence.saturationCounts.slowSession) * ctx.config.confidence.sessionAggregateFactor, evidence: { - attemptIds: ctx.attempts + attemptSourceEventIds: ctx.attempts .filter((attempt) => slowSessions.includes(attempt.sessionId)) - .map((attempt) => attempt.id ?? 0), + .map((attempt) => attempt.sourceEventId), summary: `${wrongInSlow} wrong attempts occurred in sessions slower than ${ratio}x the session-mean baseline`, values: { slowSessionCount: slowSessions.length, diff --git a/packages/signal-engine/src/rules/grinding-slow.ts b/packages/signal-engine/src/rules/grinding-slow.ts index 4b620f1..a58d3a2 100644 --- a/packages/signal-engine/src/rules/grinding-slow.ts +++ b/packages/signal-engine/src/rules/grinding-slow.ts @@ -43,7 +43,7 @@ export const contract: RuleContract = { severity: severity('grinding', intensity, ctx.config), confidence: Math.min(1, slow.length / ctx.config.confidence.saturationCounts.slowWrong), evidence: { - attemptIds: slow.map((a) => a.id ?? 0), + attemptSourceEventIds: slow.map((attempt) => attempt.sourceEventId), summary: `${slow.length} of ${wrongAttempts.length} wrong attempts were slower than ${ratio}x the student's baseline`, values: { slowWrongCount: slow.length, diff --git a/packages/signal-engine/src/rules/guessing-cycling.ts b/packages/signal-engine/src/rules/guessing-cycling.ts index 33591ef..0772d54 100644 --- a/packages/signal-engine/src/rules/guessing-cycling.ts +++ b/packages/signal-engine/src/rules/guessing-cycling.ts @@ -18,7 +18,7 @@ export const contract: RuleContract = { (a, b) => a.attempt.submittedAt.getTime() - b.attempt.submittedAt.getTime() || a.attempt.attemptIndex - b.attempt.attemptIndex || - (a.attempt.id ?? 0) - (b.attempt.id ?? 0) + a.attempt.sourceEventId.localeCompare(b.attempt.sourceEventId) ); if (wrong.length === 0) return { type: 'clear' }; const minimumEvidence = ctx.config.answerChoice.minimumEvidence; @@ -55,7 +55,7 @@ export const contract: RuleContract = { severity: severity('guessing', fraction, ctx.config), confidence: Math.min(1, wrong.length / ctx.config.confidence.saturationCounts.cycling), evidence: { - attemptIds: wrong.map(({ attempt }) => attempt.id ?? 0), + attemptSourceEventIds: wrong.map(({ attempt }) => attempt.sourceEventId), summary: `${cyclingTransitions} of ${eligibleTransitions} wrong-answer transitions advanced to the next position`, values: { cyclingTransitions, diff --git a/packages/signal-engine/src/rules/guessing-fast-wrong.ts b/packages/signal-engine/src/rules/guessing-fast-wrong.ts index eb8fe4b..71302c7 100644 --- a/packages/signal-engine/src/rules/guessing-fast-wrong.ts +++ b/packages/signal-engine/src/rules/guessing-fast-wrong.ts @@ -45,7 +45,7 @@ export const contract: RuleContract = { severity: severity('guessing', intensity, ctx.config), confidence: Math.min(1, fastWrong.length / ctx.config.confidence.saturationCounts.fastWrong), evidence: { - attemptIds: fastWrong.map((a) => a.id ?? 0), + attemptSourceEventIds: fastWrong.map((attempt) => attempt.sourceEventId), summary: `${fastWrong.length} of ${wrongAttempts.length} wrong attempts were faster than ${ratio}x the student's baseline`, values: { fastWrongCount: fastWrong.length, diff --git a/packages/signal-engine/src/rules/guessing-inversion.ts b/packages/signal-engine/src/rules/guessing-inversion.ts index 751729a..659834f 100644 --- a/packages/signal-engine/src/rules/guessing-inversion.ts +++ b/packages/signal-engine/src/rules/guessing-inversion.ts @@ -57,7 +57,9 @@ export const contract: RuleContract = { (wrongEasy.length + correctHard.length) / ctx.config.confidence.saturationCounts.inversion ), evidence: { - attemptIds: [...wrongEasy, ...correctHard].map((a) => a.id ?? 0), + attemptSourceEventIds: [...wrongEasy, ...correctHard].map( + (attempt) => attempt.sourceEventId + ), summary: `${wrongEasy.length} of ${easy.length} easy items were wrong while ${correctHard.length} of ${hard.length} harder items were correct`, values: { easyAttemptCount: easy.length, diff --git a/packages/signal-engine/src/rules/guessing-scattered.ts b/packages/signal-engine/src/rules/guessing-scattered.ts index 46ce7fd..faaf4f6 100644 --- a/packages/signal-engine/src/rules/guessing-scattered.ts +++ b/packages/signal-engine/src/rules/guessing-scattered.ts @@ -39,7 +39,7 @@ export const contract: RuleContract = { severity: severity('guessing', normalized, ctx.config), confidence: Math.min(1, wrong.length / ctx.config.confidence.saturationCounts.scattered), evidence: { - attemptIds: wrong.map((attempt) => attempt.id ?? 0), + attemptSourceEventIds: wrong.map((attempt) => attempt.sourceEventId), summary: `Wrong answers are scattered across ${Object.keys(scatter.keyCounts).length} observed choices (entropy ${normalized.toFixed(2)})`, values: { entropy: scatter.entropy, diff --git a/packages/signal-engine/src/rules/hint-farming.ts b/packages/signal-engine/src/rules/hint-farming.ts index 68067d5..74cf57c 100644 --- a/packages/signal-engine/src/rules/hint-farming.ts +++ b/packages/signal-engine/src/rules/hint-farming.ts @@ -36,7 +36,7 @@ export const contract: RuleContract = { ctx.attempts.length / ctx.config.confidence.saturationCounts.hintFarming ), evidence: { - attemptIds: hintAttempts.map((a) => a.id ?? 0), + attemptSourceEventIds: hintAttempts.map((attempt) => attempt.sourceEventId), summary: `${hintAttempts.length} of ${ctx.attempts.length} attempts used hints`, values: { hintCount: hintAttempts.length, diff --git a/packages/signal-engine/src/rules/prereq-blocked.ts b/packages/signal-engine/src/rules/prereq-blocked.ts index aaaf483..a699065 100644 --- a/packages/signal-engine/src/rules/prereq-blocked.ts +++ b/packages/signal-engine/src/rules/prereq-blocked.ts @@ -66,7 +66,7 @@ export const contract: RuleContract = { severity: severity('prerequisite_gap', intensity, ctx.config), confidence: Math.min(1, weightedMasteryDeficit / totalStrength), evidence: { - attemptIds: ctx.attempts.map((a) => a.id ?? 0), + attemptSourceEventIds: ctx.attempts.map((attempt) => attempt.sourceEventId), summary: `Prerequisite ${weakest.id} (${weakest.name}) is weak (mastery ${weakest.value ?? 'unknown'})`, values: { prereqId: weakest.id, diff --git a/packages/signal-engine/src/rules/prereq-misconception.ts b/packages/signal-engine/src/rules/prereq-misconception.ts index 0c57e0f..3e1d46d 100644 --- a/packages/signal-engine/src/rules/prereq-misconception.ts +++ b/packages/signal-engine/src/rules/prereq-misconception.ts @@ -70,7 +70,7 @@ export const contract: RuleContract = { severity: severity('prerequisite_gap', concentration, ctx.config), confidence: Math.min(1, named.length / ctx.config.confidence.saturationCounts.scattered), evidence: { - attemptIds: matching.map(({ attempt }) => attempt.id ?? 0), + attemptSourceEventIds: matching.map(({ attempt }) => attempt.sourceEventId), summary: `${dominantCount} of ${wrongCount} wrong answers indicate: ${misconception}`, values: { concentration, diff --git a/packages/signal-engine/src/rules/retry-identical.ts b/packages/signal-engine/src/rules/retry-identical.ts index be614d2..9c9a25c 100644 --- a/packages/signal-engine/src/rules/retry-identical.ts +++ b/packages/signal-engine/src/rules/retry-identical.ts @@ -24,14 +24,14 @@ export const contract: RuleContract = { let identicalRetries = 0; let bestItem: string | null = null; let bestFraction = 0; - let bestAttemptIds: number[] = []; + let bestAttemptSourceEventIds: string[] = []; for (const [itemId, list] of byItem) { const ordered = [...list].sort( (a, b) => a.attempt.attemptIndex - b.attempt.attemptIndex || a.attempt.submittedAt.getTime() - b.attempt.submittedAt.getTime() || - (a.attempt.id ?? 0) - (b.attempt.id ?? 0) + a.attempt.sourceEventId.localeCompare(b.attempt.sourceEventId) ); let itemRetries = 0; const identicalPairs: Array<[Attempt, Attempt]> = []; @@ -53,8 +53,8 @@ export const contract: RuleContract = { if (fraction > bestFraction) { bestFraction = fraction; bestItem = itemId; - bestAttemptIds = Array.from( - new Set(identicalPairs.flatMap((pair) => pair.map((attempt) => attempt.id ?? 0))) + bestAttemptSourceEventIds = Array.from( + new Set(identicalPairs.flatMap((pair) => pair.map((attempt) => attempt.sourceEventId))) ); } } @@ -73,7 +73,7 @@ export const contract: RuleContract = { identicalRetries / ctx.config.confidence.saturationCounts.identicalResubmit ), evidence: { - attemptIds: bestAttemptIds, + attemptSourceEventIds: bestAttemptSourceEventIds, summary: `${identicalRetries} of ${totalRetries} wrong retries repeated the preceding answer on ${bestItem}`, values: { itemId: bestItem, diff --git a/packages/signal-engine/src/rules/retry-no-read.ts b/packages/signal-engine/src/rules/retry-no-read.ts index a337f7d..091aab7 100644 --- a/packages/signal-engine/src/rules/retry-no-read.ts +++ b/packages/signal-engine/src/rules/retry-no-read.ts @@ -55,7 +55,7 @@ export const contract: RuleContract = { fastRetries.length / ctx.config.confidence.saturationCounts.retryNoRead ), evidence: { - attemptIds: fastRetries.map((a) => a.id ?? 0), + attemptSourceEventIds: fastRetries.map((attempt) => attempt.sourceEventId), summary: `${fastRetries.length} of ${retries.length} retry attempts were faster than ${ratio}x the first-attempt median`, values: { fastRetryCount: fastRetries.length, diff --git a/packages/signal-engine/src/windows.ts b/packages/signal-engine/src/windows.ts index ea56c0b..686a635 100644 --- a/packages/signal-engine/src/windows.ts +++ b/packages/signal-engine/src/windows.ts @@ -50,6 +50,23 @@ function addLocalDays(date: LocalDate, days: number): LocalDate { const value = new Date(Date.UTC(date.year, date.month - 1, date.day + days)); return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1, day: value.getUTCDate() }; } +export function chicagoDateOnlyBoundary(value: Date | string, days = 0): Date { + const date = typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10); + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) throw new Error(`Invalid date-only value: ${date}`); + return chicagoMidnight( + addLocalDays({ year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }, days) + ); +} +export function chicagoCalendarDaysBetween(start: Date, end: Date): number { + const first = localDate(start); + const last = localDate(end); + return ( + (Date.UTC(last.year, last.month - 1, last.day) - + Date.UTC(first.year, first.month - 1, first.day)) / + (24 * 60 * 60 * 1000) + ); +} export function chicagoDaysBefore(anchor: Date, days: number): Date { return chicagoMidnight(addLocalDays(localDate(anchor), -days)); } diff --git a/packages/signal-engine/test/attendance-engine.test.ts b/packages/signal-engine/test/attendance-engine.test.ts index 877d6ff..257a588 100644 --- a/packages/signal-engine/test/attendance-engine.test.ts +++ b/packages/signal-engine/test/attendance-engine.test.ts @@ -6,8 +6,8 @@ import { contract as disengagement } from '../src/rules/disengagement.js'; const studentId = 'attendance-student'; const window = { - start: new Date('2026-07-21T00:00:00.000Z'), - end: new Date('2026-07-28T00:00:00.000Z'), + start: new Date('2026-07-21T05:00:00.000Z'), + end: new Date('2026-07-28T05:00:00.000Z'), }; function attempt(id: number, submittedAt: Date): Attempt { @@ -40,7 +40,7 @@ function run(attendanceLoaded: boolean, attendance: import('@huddle/core').Absen date.setUTCDate(date.getUTCDate() + Math.floor(index / 2)); return attempt(index + 1, date); }); - const current = Array.from({ length: 5 }, (_, index) => { + const current = Array.from({ length: 4 }, (_, index) => { const date = new Date('2026-07-24T08:00:00.000Z'); date.setUTCDate(date.getUTCDate() + index); return attempt(100 + index, date); @@ -50,7 +50,16 @@ function run(attendanceLoaded: boolean, attendance: import('@huddle/core').Absen students: [{ id: studentId, firstName: 'Taylor' }], skills: [], attempts: [...history, ...current], - items: [], + items: [ + { + id: 'mcq:TEKS.4.2A-01', + skillId: 'TEKS.4.2A', + difficulty: 0.5, + choices: [{ key: 'A', isCorrect: true }], + itemType: 'multiple_choice', + timingProfile: 'standard_multiple_choice', + }, + ], skillPrereqs: [], mastery: { at: () => ({ value: 0.8, isKnown: true as const }) }, attendance, diff --git a/packages/signal-engine/test/board-window.test.ts b/packages/signal-engine/test/board-window.test.ts index a1eb6c4..fb3bf53 100644 --- a/packages/signal-engine/test/board-window.test.ts +++ b/packages/signal-engine/test/board-window.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { boardWindows, isInHalfOpenWindow } from '../src/windows.js'; +import { + boardWindows, + chicagoCalendarDaysBetween, + chicagoDateOnlyBoundary, + isInHalfOpenWindow, +} from '../src/windows.js'; describe('Chicago half-open board windows', () => { it.each([ @@ -22,4 +27,12 @@ describe('Chicago half-open board windows', () => { isInHalfOpenWindow(window.windowEnd, { start: window.windowStart, end: window.windowEnd }) ).toBe(false); }); + + it('normalizes inclusive date-only attendance through DST as civil days', () => { + const start = chicagoDateOnlyBoundary(new Date('2026-03-08T00:00:00.000Z')); + const end = chicagoDateOnlyBoundary(new Date('2026-03-08T00:00:00.000Z'), 1); + expect(start.toISOString()).toBe('2026-03-08T06:00:00.000Z'); + expect(end.toISOString()).toBe('2026-03-09T05:00:00.000Z'); + expect(chicagoCalendarDaysBetween(start, end)).toBe(1); + }); }); diff --git a/packages/signal-engine/test/determinism.test.ts b/packages/signal-engine/test/determinism.test.ts index 62bafde..12f359c 100644 --- a/packages/signal-engine/test/determinism.test.ts +++ b/packages/signal-engine/test/determinism.test.ts @@ -72,7 +72,7 @@ describe('determinism', () => { severity: 0.8, confidence: 0.9, evidence: { - attemptIds: [2], + attemptSourceEventIds: ['evt-2'], summary: 'volume fell below the expected level', values: { expectedVolume: 8, dropFraction: 0.75 }, }, diff --git a/packages/signal-engine/test/half-open-engine.test.ts b/packages/signal-engine/test/half-open-engine.test.ts index 65b8910..28ac442 100644 --- a/packages/signal-engine/test/half-open-engine.test.ts +++ b/packages/signal-engine/test/half-open-engine.test.ts @@ -18,7 +18,7 @@ const countRule: RuleContract = { severity: 1, confidence: 1, evidence: { - attemptIds: ctx.attempts.map((entry) => entry.id ?? 0), + attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), summary: 'in-window', values: {}, }, 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 3efb30e..e207b3f 100644 --- a/packages/signal-engine/test/per-fired-rule-evidence.test.ts +++ b/packages/signal-engine/test/per-fired-rule-evidence.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import type { Item } from '@huddle/core'; import type { RuleContract } from '../src/contract.js'; import { runEngine } from '../src/engine.js'; import { makeContext, attempt, defaultSkill, defaultItems } from './rule-context.js'; @@ -17,7 +18,7 @@ describe('per-fired-rule evidence identity', () => { type: 'fired' as const, severity: index ? 0.6 : 0.8, confidence: index ? 0.7 : 0.9, - evidence: { attemptIds: [1], summary: id, values: {} }, + evidence: { attemptSourceEventIds: ['evt-1'], summary: id, values: {} }, }), })); const context = makeContext({ attempts: [attempt({ id: 1 })] }); @@ -75,7 +76,7 @@ describe('per-fired-rule evidence identity', () => { severity: 0.8, confidence: 0.9, evidence: { - attemptIds: ctx.attempts.map((entry) => entry.id ?? 0), + attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), summary: 'skill', values: {}, }, @@ -94,7 +95,7 @@ describe('per-fired-rule evidence identity', () => { severity: 0.7, confidence: 0.8, evidence: { - attemptIds: ctx.attempts.map((entry) => entry.id ?? 0), + attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), summary: 'cross', values: {}, }, @@ -102,7 +103,9 @@ describe('per-fired-rule evidence identity', () => { }, ]; const run = (attemptId: number, activeRules = rules) => { - const context = makeContext({ attempts: [attempt({ id: attemptId })] }); + const context = makeContext({ + attempts: [attempt({ id: attemptId, sourceEventId: 'stable-event' })], + }); return runEngine( { students: [context.student], @@ -132,8 +135,7 @@ describe('per-fired-rule evidence identity', () => { { ruleId: 'engagement.cross', suggestedCause: 'disengagement' }, ]); expect( - (skill.evidence as import('@huddle/core').EvidenceBundle).additionalCauses[0] - ?.finalConfidence + (skill.evidence as import('@huddle/core').EvidenceBundle).additionalCauses[0]?.finalConfidence ).toBeCloseTo(cross.finalConfidence!); expect(new Set(first.signals.map((signal) => signal.behaviorFingerprint)).size).toBe(1); expect( @@ -147,14 +149,95 @@ describe('per-fired-rule evidence identity', () => { ).toBe(signal.evidenceFingerprint); } - const changedBehavior = run(1, [ - rules[0]!, - { ...rules[1]!, version: 'v2' }, - ]); + const changedBehavior = run(1, [rules[0]!, { ...rules[1]!, version: 'v2' }]); expect( changedBehavior.signals.every( (signal) => signal.behaviorFingerprint !== skill.behaviorFingerprint ) ).toBe(true); + const changedCross = changedBehavior.signals.find( + (signal) => signal.ruleId === 'engagement.cross' + )!; + expect(changedCross.ruleVersion).toBe('v2'); + expect( + (changedCross.evidence as import('@huddle/core').EvidenceBundle).finding.ruleVersion + ).toBe('v2'); + }); + + it('orders exact evidence by source identity and preserves item metadata', () => { + const item: Item = { + id: 'numeric:TEKS.4.3E-01', + skillId: defaultSkill.id, + difficulty: 0.5, + choices: [], + itemType: 'numeric', + timingProfile: 'word_problem', + }; + const rule: RuleContract = { + id: 'numeric.exact', + version: 'numeric-v1', + emits: 'grinding', + family: 'engagement', + urgency: 0.5, + unit: 'student-skill', + requiredInputs: [], + evaluate: (ctx) => ({ + type: 'fired', + severity: 0.5, + confidence: 1, + evidence: { + attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), + summary: 'numeric', + values: {}, + }, + }), + }; + const run = (ids: [number | null, number | null], reverse = false) => { + const attempts = [ + attempt({ + id: ids[0], + itemId: item.id, + sourceEventId: 'source-z', + submittedAt: new Date('2026-07-22T08:01:00.000Z'), + }), + attempt({ + id: ids[1], + itemId: item.id, + sourceEventId: 'source-a', + submittedAt: new Date('2026-07-22T08:01:00.000Z'), + }), + ]; + const context = makeContext({ attempts: reverse ? attempts.reverse() : attempts }); + return runEngine( + { + students: [context.student], + skills: [defaultSkill], + attempts: [...context.attempts], + items: [item], + skillPrereqs: [], + mastery: context.mastery, + attendance: [], + attendanceLoaded: true, + config: context.config, + now: context.asOf, + window: context.window, + }, + [rule] + ).signals[0]!; + }; + + const first = run([null, null]); + const bundle = first.evidence as import('@huddle/core').EvidenceBundle; + expect(bundle.attempts.map((entry) => entry.sourceEventId)).toEqual(['source-a', 'source-z']); + expect(bundle.attempts.map((entry) => entry.attemptId)).toEqual([null, null]); + expect( + bundle.attempts.map(({ itemType, timingProfile }) => ({ itemType, timingProfile })) + ).toEqual([ + { itemType: 'numeric', timingProfile: 'word_problem' }, + { itemType: 'numeric', timingProfile: 'word_problem' }, + ]); + + const rekeyed = run([11, 10], true); + expect(rekeyed.evidenceFingerprint).toBe(first.evidenceFingerprint); }); }); diff --git a/packages/signal-engine/test/rule-context.ts b/packages/signal-engine/test/rule-context.ts index 74c7881..c4244b3 100644 --- a/packages/signal-engine/test/rule-context.ts +++ b/packages/signal-engine/test/rule-context.ts @@ -67,7 +67,7 @@ export function makeContext(overrides: Partial = {}): RuleContext { }; } -export function attempt(props: Partial & { id: number }): Attempt { +export function attempt(props: Partial & { id: number | null }): Attempt { return { studentId: '11111111-1111-1111-1111-111111111111', skillId: 'TEKS.4.3E', diff --git a/scripts/nightly.ts b/scripts/nightly.ts index 9e26279..f7a7d18 100644 --- a/scripts/nightly.ts +++ b/scripts/nightly.ts @@ -107,9 +107,19 @@ async function loadItems(client: PoolClient) { skill_id: string; difficulty: number; choices: unknown; - }>('SELECT id, skill_id, difficulty, choices FROM item'); + item_type: Item['itemType']; + timing_profile: Item['timingProfile']; + }>('SELECT id, skill_id, difficulty, choices, item_type, timing_profile FROM item'); return rows.map( - (r) => ({ id: r.id, skillId: r.skill_id, difficulty: r.difficulty, choices: r.choices }) as Item + (r) => + ({ + id: r.id, + skillId: r.skill_id, + difficulty: r.difficulty, + choices: r.choices, + itemType: r.item_type, + timingProfile: r.timing_profile, + }) as Item ); } diff --git a/specs/001-huddle-triage-board/compliance-report.md b/specs/001-huddle-triage-board/compliance-report.md index fc9cf53..6d7a9c2 100644 --- a/specs/001-huddle-triage-board/compliance-report.md +++ b/specs/001-huddle-triage-board/compliance-report.md @@ -150,7 +150,7 @@ the divergence count. | FR-017 confidence first-class | `RuleOutcome.fired.confidence`; `confidence.ts` | full_match | 0.95 | | FR-018 / invariant 4 winsorization | `winsorize.ts:22-30` correct; `default` bound unreachable at :11; never invoked in any live path | **partial_match** | 0.90 | | FR-019 / Principle VII relative-to-self | All baselines are per-student; no cohort statistic anywhere in `signal-engine` | full_match | 0.95 | -| FR-020 record what fired the rule | `Evidence.attemptIds` + `values` on every rule | full_match | 0.95 | +| FR-020 record what fired the rule | `Evidence.attemptSourceEventIds` + `values` on every rule | full_match | 0.95 | | FR-021 conflict ⇒ reduce confidence + disclose | `engine.ts:117-133`; `confidence.ts:52` | full_match | 0.90 | | FR-022 self-contained bundle | `evidence.ts:36-153` assembles it correctly… | full_match | 0.95 | | FR-023/FR-024 generation sees only the bundle | …`narrator/src/prompt.ts:23` then strips it to almost nothing | **mismatch** | 0.98 | diff --git a/specs/001-huddle-triage-board/contracts/eval-harness.md b/specs/001-huddle-triage-board/contracts/eval-harness.md index 62c5fbb..17e0c44 100644 --- a/specs/001-huddle-triage-board/contracts/eval-harness.md +++ b/specs/001-huddle-triage-board/contracts/eval-harness.md @@ -140,14 +140,14 @@ the latest compatible accepted run fails until explicitly acknowledged. This Git is unrelated to automatic guide report acknowledgment in the application database. Markdown is derived output and never the regression source. -## Release-promotion gate left open +## Portfolio and release gates The seven constitutional merge gates remain mandatory and are not weakened by the quick-demo delta. -A separate product decision is intentionally unresolved: whether an internal synthetic quick demo may -be promoted to an external demo/pilot before the complete accepted eval release bundle, who may -approve an acknowledged regression, and what evidence that approval requires. Until the captain -closes that gate, no external accuracy-backed release claim is permitted. This contract does not -invent an approver or turn guide report acknowledgments into eval acceptance. +The hosted synthetic portfolio work sample may be promoted after its executable grounding and +deterministic-fallback regressions pass, provided it makes no diagnosis-accuracy claim. The complete +accepted simulator, holdout, permutation-control, and accuracy regression bundle remains mandatory +before any pilot or any public or private accuracy-backed claim. This contract does not invent an +accuracy-regression approver or turn guide report acknowledgments into eval acceptance. ## Prohibitions diff --git a/specs/001-huddle-triage-board/contracts/evidence-bundle.md b/specs/001-huddle-triage-board/contracts/evidence-bundle.md index 07092dd..2115975 100644 --- a/specs/001-huddle-triage-board/contracts/evidence-bundle.md +++ b/specs/001-huddle-triage-board/contracts/evidence-bundle.md @@ -38,10 +38,12 @@ export interface EvidenceBundle { localDays: 7; }; attempts: Array<{ - attemptId: number; + attemptId: number | null; // persistence surrogate for drill-down only; never semantic identity + sourceEventId: string; // stable activity identity used for selection, ordering, and fingerprints ordinal: number; skill: { code: string; name: string }; // required even for cross-skill bundles itemType: ItemType; + timingProfile: TimingProfile; submittedAt: string; // full ISO timestamp; drill-down and date grounding use this exact fact isCorrect: boolean; elapsedMs: number | null; // genuine per-attempt wall-clock only From 61253dbf7f83586ef5a89876423b8eb9d8ca5b89 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 13:24:31 -0500 Subject: [PATCH 4/7] no-mistakes(review): Harden activity identity and civil-date boundaries --- packages/core/src/fixtures/loader.ts | 21 ++++-- packages/core/src/schemas.ts | 7 +- packages/core/src/types.ts | 9 +-- packages/db/package.json | 4 + packages/db/src/activity-identity.ts | 7 ++ packages/db/src/index.ts | 1 + packages/db/test/activity-identity.test.ts | 15 ++++ .../narrator/test/request-contract.test.ts | 2 +- packages/signal-engine/src/contract.ts | 2 +- packages/signal-engine/src/engine.ts | 4 +- packages/signal-engine/src/evidence.ts | 8 +- packages/signal-engine/src/rules/decay.ts | 2 +- .../signal-engine/src/rules/disengagement.ts | 2 +- .../src/rules/grinding-repeated.ts | 4 +- .../src/rules/grinding-session.ts | 4 +- .../signal-engine/src/rules/grinding-slow.ts | 2 +- .../src/rules/guessing-cycling.ts | 4 +- .../src/rules/guessing-fast-wrong.ts | 2 +- .../src/rules/guessing-inversion.ts | 4 +- .../src/rules/guessing-scattered.ts | 2 +- .../signal-engine/src/rules/hint-farming.ts | 2 +- .../signal-engine/src/rules/prereq-blocked.ts | 2 +- .../src/rules/prereq-misconception.ts | 2 +- .../src/rules/retry-identical.ts | 10 +-- .../signal-engine/src/rules/retry-no-read.ts | 2 +- packages/signal-engine/src/windows.ts | 20 +++-- .../test/attendance-engine.test.ts | 7 +- .../signal-engine/test/board-window.test.ts | 4 +- .../signal-engine/test/determinism.test.ts | 2 +- .../test/disengagement-grain.test.ts | 3 +- .../test/grinding-session-engine.test.ts | 3 +- .../test/half-open-engine.test.ts | 2 +- packages/signal-engine/test/helpers.ts | 21 ++++-- .../test/per-fired-rule-evidence.test.ts | 73 ++++++++++++++++--- packages/signal-engine/test/rule-context.ts | 3 +- scripts/nightly.ts | 13 ++-- .../compliance-report.md | 2 +- .../contracts/evidence-bundle.md | 2 +- 38 files changed, 186 insertions(+), 93 deletions(-) create mode 100644 packages/db/src/activity-identity.ts create mode 100644 packages/db/test/activity-identity.test.ts diff --git a/packages/core/src/fixtures/loader.ts b/packages/core/src/fixtures/loader.ts index 6eea29d..93921cc 100644 --- a/packages/core/src/fixtures/loader.ts +++ b/packages/core/src/fixtures/loader.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { Attempt } from '../types.js'; @@ -76,13 +77,19 @@ export async function loadFixture(name: string): Promise { return { dataset: 'synthetic-test', name: parsed.name ?? name, - attempts: parsed.attempts.map((a: Record) => ({ - ...a, - startedAt: new Date(a.startedAt as string), - submittedAt: new Date(a.submittedAt as string), - ingestedAt: new Date((a.ingestedAt as string) ?? Date.now()), - timingWasWinsorized: (a.timingWasWinsorized as boolean | undefined) ?? false, - })) as Attempt[], + attempts: parsed.attempts.map((a: Record) => { + const { source, sourceEventId, ...attempt } = a; + return { + ...attempt, + activityId: createHash('sha256') + .update(JSON.stringify([source, sourceEventId])) + .digest('hex'), + startedAt: new Date(a.startedAt as string), + submittedAt: new Date(a.submittedAt as string), + ingestedAt: new Date((a.ingestedAt as string) ?? Date.now()), + timingWasWinsorized: (a.timingWasWinsorized as boolean | undefined) ?? false, + }; + }) as Attempt[], }; } diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index c942688..4d8cfcf 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -57,8 +57,8 @@ export const ItemSchema = z.object({ export const AbsenceSpanSchema = z.object({ id: z.number(), studentId: z.string().uuid(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), source: z.string(), }); @@ -71,6 +71,7 @@ export const AnswerChoiceSchema = z.object({ 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(), @@ -86,8 +87,6 @@ export const AttemptSchema = z isCorrect: z.boolean(), answerGiven: AnswerChoiceSchema.nullable(), hintsUsed: z.number().int().min(0).default(0), - source: z.string(), - sourceEventId: z.string(), ingestedAt: z.coerce.date(), }) .refine( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a979a72..1fa5ed6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -77,8 +77,8 @@ export interface Item { export interface AbsenceSpan { id: number; studentId: string; - startDate: Date; - endDate: Date; + startDate: string; + endDate: string; source: string; } export type AnswerChoice = { key: string; label?: string; misconceptionId?: string }; @@ -103,6 +103,7 @@ export interface NormalizedAttempt { } export interface Attempt { id: number | null; + activityId: string; studentId: string; skillId: string; itemId: string; @@ -118,8 +119,6 @@ export interface Attempt { isCorrect: boolean; answerGiven: AnswerChoice | null; hintsUsed: number; - source: string; - sourceEventId: string; ingestedAt: Date; } @@ -217,7 +216,7 @@ export interface EvidenceBundle { }; attempts: Array<{ attemptId: number | null; - sourceEventId: string; + activityId: string; ordinal: number; skill: { code: string; name: string }; itemType: ItemType; diff --git a/packages/db/package.json b/packages/db/package.json index c09bf50..0965bca 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -39,6 +39,10 @@ "./acknowledgement.js": { "import": "./dist/src/acknowledgement.js", "types": "./dist/src/acknowledgement.d.ts" + }, + "./activity-identity.js": { + "import": "./dist/src/activity-identity.js", + "types": "./dist/src/activity-identity.d.ts" } }, "scripts": { diff --git a/packages/db/src/activity-identity.ts b/packages/db/src/activity-identity.ts new file mode 100644 index 0000000..65e388a --- /dev/null +++ b/packages/db/src/activity-identity.ts @@ -0,0 +1,7 @@ +import { createHash } from 'node:crypto'; + +export function opaqueActivityId(source: string, sourceEventId: string): string { + return createHash('sha256') + .update(JSON.stringify([source, sourceEventId])) + .digest('hex'); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 14d5631..2946bb0 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -10,3 +10,4 @@ export { clearBoardForGuide, } from './scoped.js'; export type { Queryable, StudentRow, GuideScope, TriageBoardRow, MasteryRow } from './scoped.js'; +export { opaqueActivityId } from './activity-identity.js'; diff --git a/packages/db/test/activity-identity.test.ts b/packages/db/test/activity-identity.test.ts new file mode 100644 index 0000000..d97570d --- /dev/null +++ b/packages/db/test/activity-identity.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { opaqueActivityId } from '../src/activity-identity.js'; + +describe('opaque activity identity', () => { + it('uses the complete source event identity without exposing either component', () => { + const first = opaqueActivityId('source-a', 'event-1'); + const second = opaqueActivityId('source-b', 'event-1'); + + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(second).toMatch(/^[a-f0-9]{64}$/); + expect(first).not.toBe(second); + expect(first).not.toContain('source-a'); + expect(first).not.toContain('event-1'); + }); +}); diff --git a/packages/narrator/test/request-contract.test.ts b/packages/narrator/test/request-contract.test.ts index a150047..ae84c4d 100644 --- a/packages/narrator/test/request-contract.test.ts +++ b/packages/narrator/test/request-contract.test.ts @@ -32,7 +32,7 @@ const bundle: EvidenceBundle = { attempts: [ { attemptId: 1, - sourceEventId: 'event-1', + activityId: 'event-1', ordinal: 1, skill: { code: 'TEKS.4.3A', name: 'Unit fractions' }, itemType: 'multiple_choice', diff --git a/packages/signal-engine/src/contract.ts b/packages/signal-engine/src/contract.ts index 9688c2e..aeec4bf 100644 --- a/packages/signal-engine/src/contract.ts +++ b/packages/signal-engine/src/contract.ts @@ -45,7 +45,7 @@ export interface RuleContract { } export interface Evidence { - attemptSourceEventIds: string[]; + attemptActivityIds: string[]; summary: string; values: Record; } diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index 7d0e447..ed39b45 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -351,9 +351,9 @@ function scopesOverlap(a: PendingSignal, b: PendingSignal): boolean { ? b.scope.skill.code : null; if (skill == null) return false; - const contributingIds = new Set(crossSkill.outcome.evidence.attemptSourceEventIds); + const contributingIds = new Set(crossSkill.outcome.evidence.attemptActivityIds); return crossSkill.ctx.attempts.some( - (attempt) => attempt.skillId === skill && contributingIds.has(attempt.sourceEventId) + (attempt) => attempt.skillId === skill && contributingIds.has(attempt.activityId) ); } diff --git a/packages/signal-engine/src/evidence.ts b/packages/signal-engine/src/evidence.ts index a821666..1247af5 100644 --- a/packages/signal-engine/src/evidence.ts +++ b/packages/signal-engine/src/evidence.ts @@ -35,13 +35,13 @@ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContex const firedOutcome = ctx.ruleOutcomes.find((result) => result.ruleId === ctx.ruleId)?.outcome; if (firedOutcome?.type !== 'fired') throw new Error(`Fired rule ${ctx.ruleId} has no evidence`); const firedEvidence = firedOutcome.evidence; - const contributingIds = new Set(firedEvidence.attemptSourceEventIds); + const contributingIds = new Set(firedEvidence.attemptActivityIds); const attempts = ctx.attempts - .filter((attempt) => contributingIds.has(attempt.sourceEventId)) + .filter((attempt) => contributingIds.has(attempt.activityId)) .sort( (a, b) => a.submittedAt.getTime() - b.submittedAt.getTime() || - a.sourceEventId.localeCompare(b.sourceEventId) + a.activityId.localeCompare(b.activityId) ); const wrongAttempts = attempts.filter((attempt) => !attempt.isCorrect); const wrongDurations = wrongAttempts @@ -63,7 +63,7 @@ export function assembleEvidenceBundle(signal: Signal, ctx: EvidenceBundleContex const chosen = item?.choices.find((choice) => choice.key === attempt.answerGiven?.key); return { attemptId: attempt.id, - sourceEventId: attempt.sourceEventId, + activityId: attempt.activityId, ordinal: index + 1, skill: ctx.skill ? { code: ctx.skill.id, name: ctx.skill.name } diff --git a/packages/signal-engine/src/rules/decay.ts b/packages/signal-engine/src/rules/decay.ts index 4a5e37a..4183066 100644 --- a/packages/signal-engine/src/rules/decay.ts +++ b/packages/signal-engine/src/rules/decay.ts @@ -41,7 +41,7 @@ export const contract: RuleContract = { severity: severity('decay', intensity, ctx.config), confidence: Math.min(1, drop * ctx.config.confidence.saturationCounts.decayMultiplier), evidence: { - attemptSourceEventIds: ctx.attempts.map((attempt) => attempt.sourceEventId), + attemptActivityIds: ctx.attempts.map((attempt) => attempt.activityId), summary: `Mastery dropped from ${previous.toFixed(2)} to ${current.toFixed(2)}`, values: { previous, diff --git a/packages/signal-engine/src/rules/disengagement.ts b/packages/signal-engine/src/rules/disengagement.ts index 8afa35f..b8cf45d 100644 --- a/packages/signal-engine/src/rules/disengagement.ts +++ b/packages/signal-engine/src/rules/disengagement.ts @@ -36,7 +36,7 @@ export const contract: RuleContract = { expectedVolume / ctx.config.confidence.saturationCounts.disengagementBaselineVolume ), evidence: { - attemptSourceEventIds: ctx.attempts.map((attempt) => attempt.sourceEventId), + attemptActivityIds: ctx.attempts.map((attempt) => attempt.activityId), summary: `Attempt volume dropped from expected ${expectedVolume.toFixed(1)} to ${currentVolume}`, values: { currentVolume, expectedVolume, dropFraction }, }, diff --git a/packages/signal-engine/src/rules/grinding-repeated.ts b/packages/signal-engine/src/rules/grinding-repeated.ts index d6fb974..43fcf48 100644 --- a/packages/signal-engine/src/rules/grinding-repeated.ts +++ b/packages/signal-engine/src/rules/grinding-repeated.ts @@ -9,7 +9,7 @@ function repeatedWrongNoHelp(itemAttempts: Attempt[], ctx: RuleContext): Attempt (a, b) => a.attemptIndex - b.attemptIndex || a.submittedAt.getTime() - b.submittedAt.getTime() || - a.sourceEventId.localeCompare(b.sourceEventId) + a.activityId.localeCompare(b.activityId) ); if (ordered.length < 2 || !ordered.some((attempt) => attempt.attemptIndex >= 2)) return []; if (ordered.some((attempt) => attempt.isCorrect || attempt.hintsUsed > 0)) return []; @@ -60,7 +60,7 @@ export const contract: RuleContract = { matched.length / ctx.config.confidence.saturationCounts.repeatedNoHelp ), evidence: { - attemptSourceEventIds: matched.map((attempt) => attempt.sourceEventId), + attemptActivityIds: matched.map((attempt) => attempt.activityId), summary: `${matched.length} repeated attempts followed entirely unsuccessful, hint-free item sequences`, values: { repeatedWrongNoHelpCount: matched.length, diff --git a/packages/signal-engine/src/rules/grinding-session.ts b/packages/signal-engine/src/rules/grinding-session.ts index bdd3db1..5c6e268 100644 --- a/packages/signal-engine/src/rules/grinding-session.ts +++ b/packages/signal-engine/src/rules/grinding-session.ts @@ -87,9 +87,9 @@ export const contract: RuleContract = { Math.min(1, wrongInSlow / ctx.config.confidence.saturationCounts.slowSession) * ctx.config.confidence.sessionAggregateFactor, evidence: { - attemptSourceEventIds: ctx.attempts + attemptActivityIds: ctx.attempts .filter((attempt) => slowSessions.includes(attempt.sessionId)) - .map((attempt) => attempt.sourceEventId), + .map((attempt) => attempt.activityId), summary: `${wrongInSlow} wrong attempts occurred in sessions slower than ${ratio}x the session-mean baseline`, values: { slowSessionCount: slowSessions.length, diff --git a/packages/signal-engine/src/rules/grinding-slow.ts b/packages/signal-engine/src/rules/grinding-slow.ts index a58d3a2..5d1eec3 100644 --- a/packages/signal-engine/src/rules/grinding-slow.ts +++ b/packages/signal-engine/src/rules/grinding-slow.ts @@ -43,7 +43,7 @@ export const contract: RuleContract = { severity: severity('grinding', intensity, ctx.config), confidence: Math.min(1, slow.length / ctx.config.confidence.saturationCounts.slowWrong), evidence: { - attemptSourceEventIds: slow.map((attempt) => attempt.sourceEventId), + attemptActivityIds: slow.map((attempt) => attempt.activityId), summary: `${slow.length} of ${wrongAttempts.length} wrong attempts were slower than ${ratio}x the student's baseline`, values: { slowWrongCount: slow.length, diff --git a/packages/signal-engine/src/rules/guessing-cycling.ts b/packages/signal-engine/src/rules/guessing-cycling.ts index 0772d54..bb8472f 100644 --- a/packages/signal-engine/src/rules/guessing-cycling.ts +++ b/packages/signal-engine/src/rules/guessing-cycling.ts @@ -18,7 +18,7 @@ export const contract: RuleContract = { (a, b) => a.attempt.submittedAt.getTime() - b.attempt.submittedAt.getTime() || a.attempt.attemptIndex - b.attempt.attemptIndex || - a.attempt.sourceEventId.localeCompare(b.attempt.sourceEventId) + a.attempt.activityId.localeCompare(b.attempt.activityId) ); if (wrong.length === 0) return { type: 'clear' }; const minimumEvidence = ctx.config.answerChoice.minimumEvidence; @@ -55,7 +55,7 @@ export const contract: RuleContract = { severity: severity('guessing', fraction, ctx.config), confidence: Math.min(1, wrong.length / ctx.config.confidence.saturationCounts.cycling), evidence: { - attemptSourceEventIds: wrong.map(({ attempt }) => attempt.sourceEventId), + attemptActivityIds: wrong.map(({ attempt }) => attempt.activityId), summary: `${cyclingTransitions} of ${eligibleTransitions} wrong-answer transitions advanced to the next position`, values: { cyclingTransitions, diff --git a/packages/signal-engine/src/rules/guessing-fast-wrong.ts b/packages/signal-engine/src/rules/guessing-fast-wrong.ts index 71302c7..5f83d28 100644 --- a/packages/signal-engine/src/rules/guessing-fast-wrong.ts +++ b/packages/signal-engine/src/rules/guessing-fast-wrong.ts @@ -45,7 +45,7 @@ export const contract: RuleContract = { severity: severity('guessing', intensity, ctx.config), confidence: Math.min(1, fastWrong.length / ctx.config.confidence.saturationCounts.fastWrong), evidence: { - attemptSourceEventIds: fastWrong.map((attempt) => attempt.sourceEventId), + attemptActivityIds: fastWrong.map((attempt) => attempt.activityId), summary: `${fastWrong.length} of ${wrongAttempts.length} wrong attempts were faster than ${ratio}x the student's baseline`, values: { fastWrongCount: fastWrong.length, diff --git a/packages/signal-engine/src/rules/guessing-inversion.ts b/packages/signal-engine/src/rules/guessing-inversion.ts index 659834f..d1ac3f2 100644 --- a/packages/signal-engine/src/rules/guessing-inversion.ts +++ b/packages/signal-engine/src/rules/guessing-inversion.ts @@ -57,9 +57,7 @@ export const contract: RuleContract = { (wrongEasy.length + correctHard.length) / ctx.config.confidence.saturationCounts.inversion ), evidence: { - attemptSourceEventIds: [...wrongEasy, ...correctHard].map( - (attempt) => attempt.sourceEventId - ), + attemptActivityIds: [...wrongEasy, ...correctHard].map((attempt) => attempt.activityId), summary: `${wrongEasy.length} of ${easy.length} easy items were wrong while ${correctHard.length} of ${hard.length} harder items were correct`, values: { easyAttemptCount: easy.length, diff --git a/packages/signal-engine/src/rules/guessing-scattered.ts b/packages/signal-engine/src/rules/guessing-scattered.ts index faaf4f6..244cc83 100644 --- a/packages/signal-engine/src/rules/guessing-scattered.ts +++ b/packages/signal-engine/src/rules/guessing-scattered.ts @@ -39,7 +39,7 @@ export const contract: RuleContract = { severity: severity('guessing', normalized, ctx.config), confidence: Math.min(1, wrong.length / ctx.config.confidence.saturationCounts.scattered), evidence: { - attemptSourceEventIds: wrong.map((attempt) => attempt.sourceEventId), + attemptActivityIds: wrong.map((attempt) => attempt.activityId), summary: `Wrong answers are scattered across ${Object.keys(scatter.keyCounts).length} observed choices (entropy ${normalized.toFixed(2)})`, values: { entropy: scatter.entropy, diff --git a/packages/signal-engine/src/rules/hint-farming.ts b/packages/signal-engine/src/rules/hint-farming.ts index 74cf57c..7e0440e 100644 --- a/packages/signal-engine/src/rules/hint-farming.ts +++ b/packages/signal-engine/src/rules/hint-farming.ts @@ -36,7 +36,7 @@ export const contract: RuleContract = { ctx.attempts.length / ctx.config.confidence.saturationCounts.hintFarming ), evidence: { - attemptSourceEventIds: hintAttempts.map((attempt) => attempt.sourceEventId), + attemptActivityIds: hintAttempts.map((attempt) => attempt.activityId), summary: `${hintAttempts.length} of ${ctx.attempts.length} attempts used hints`, values: { hintCount: hintAttempts.length, diff --git a/packages/signal-engine/src/rules/prereq-blocked.ts b/packages/signal-engine/src/rules/prereq-blocked.ts index a699065..3d2c0d3 100644 --- a/packages/signal-engine/src/rules/prereq-blocked.ts +++ b/packages/signal-engine/src/rules/prereq-blocked.ts @@ -66,7 +66,7 @@ export const contract: RuleContract = { severity: severity('prerequisite_gap', intensity, ctx.config), confidence: Math.min(1, weightedMasteryDeficit / totalStrength), evidence: { - attemptSourceEventIds: ctx.attempts.map((attempt) => attempt.sourceEventId), + attemptActivityIds: ctx.attempts.map((attempt) => attempt.activityId), summary: `Prerequisite ${weakest.id} (${weakest.name}) is weak (mastery ${weakest.value ?? 'unknown'})`, values: { prereqId: weakest.id, diff --git a/packages/signal-engine/src/rules/prereq-misconception.ts b/packages/signal-engine/src/rules/prereq-misconception.ts index 3e1d46d..024aa0f 100644 --- a/packages/signal-engine/src/rules/prereq-misconception.ts +++ b/packages/signal-engine/src/rules/prereq-misconception.ts @@ -70,7 +70,7 @@ export const contract: RuleContract = { severity: severity('prerequisite_gap', concentration, ctx.config), confidence: Math.min(1, named.length / ctx.config.confidence.saturationCounts.scattered), evidence: { - attemptSourceEventIds: matching.map(({ attempt }) => attempt.sourceEventId), + attemptActivityIds: matching.map(({ attempt }) => attempt.activityId), summary: `${dominantCount} of ${wrongCount} wrong answers indicate: ${misconception}`, values: { concentration, diff --git a/packages/signal-engine/src/rules/retry-identical.ts b/packages/signal-engine/src/rules/retry-identical.ts index 9c9a25c..22156f7 100644 --- a/packages/signal-engine/src/rules/retry-identical.ts +++ b/packages/signal-engine/src/rules/retry-identical.ts @@ -24,14 +24,14 @@ export const contract: RuleContract = { let identicalRetries = 0; let bestItem: string | null = null; let bestFraction = 0; - let bestAttemptSourceEventIds: string[] = []; + let bestAttemptActivityIds: string[] = []; for (const [itemId, list] of byItem) { const ordered = [...list].sort( (a, b) => a.attempt.attemptIndex - b.attempt.attemptIndex || a.attempt.submittedAt.getTime() - b.attempt.submittedAt.getTime() || - a.attempt.sourceEventId.localeCompare(b.attempt.sourceEventId) + a.attempt.activityId.localeCompare(b.attempt.activityId) ); let itemRetries = 0; const identicalPairs: Array<[Attempt, Attempt]> = []; @@ -53,8 +53,8 @@ export const contract: RuleContract = { if (fraction > bestFraction) { bestFraction = fraction; bestItem = itemId; - bestAttemptSourceEventIds = Array.from( - new Set(identicalPairs.flatMap((pair) => pair.map((attempt) => attempt.sourceEventId))) + bestAttemptActivityIds = Array.from( + new Set(identicalPairs.flatMap((pair) => pair.map((attempt) => attempt.activityId))) ); } } @@ -73,7 +73,7 @@ export const contract: RuleContract = { identicalRetries / ctx.config.confidence.saturationCounts.identicalResubmit ), evidence: { - attemptSourceEventIds: bestAttemptSourceEventIds, + attemptActivityIds: bestAttemptActivityIds, summary: `${identicalRetries} of ${totalRetries} wrong retries repeated the preceding answer on ${bestItem}`, values: { itemId: bestItem, diff --git a/packages/signal-engine/src/rules/retry-no-read.ts b/packages/signal-engine/src/rules/retry-no-read.ts index 091aab7..05f9bf3 100644 --- a/packages/signal-engine/src/rules/retry-no-read.ts +++ b/packages/signal-engine/src/rules/retry-no-read.ts @@ -55,7 +55,7 @@ export const contract: RuleContract = { fastRetries.length / ctx.config.confidence.saturationCounts.retryNoRead ), evidence: { - attemptSourceEventIds: fastRetries.map((attempt) => attempt.sourceEventId), + attemptActivityIds: fastRetries.map((attempt) => attempt.activityId), summary: `${fastRetries.length} of ${retries.length} retry attempts were faster than ${ratio}x the first-attempt median`, values: { fastRetryCount: fastRetries.length, diff --git a/packages/signal-engine/src/windows.ts b/packages/signal-engine/src/windows.ts index 686a635..b464baa 100644 --- a/packages/signal-engine/src/windows.ts +++ b/packages/signal-engine/src/windows.ts @@ -50,13 +50,19 @@ function addLocalDays(date: LocalDate, days: number): LocalDate { const value = new Date(Date.UTC(date.year, date.month - 1, date.day + days)); return { year: value.getUTCFullYear(), month: value.getUTCMonth() + 1, day: value.getUTCDate() }; } -export function chicagoDateOnlyBoundary(value: Date | string, days = 0): Date { - const date = typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10); - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); - if (!match) throw new Error(`Invalid date-only value: ${date}`); - return chicagoMidnight( - addLocalDays({ year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }, days) - ); +export function chicagoDateOnlyBoundary(value: string, days = 0): Date { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) throw new Error(`Invalid date-only value: ${value}`); + const date = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; + const normalized = addLocalDays(date, 0); + if ( + normalized.year !== date.year || + normalized.month !== date.month || + normalized.day !== date.day + ) { + throw new Error(`Invalid date-only value: ${value}`); + } + return chicagoMidnight(addLocalDays(date, days)); } export function chicagoCalendarDaysBetween(start: Date, end: Date): number { const first = localDate(start); diff --git a/packages/signal-engine/test/attendance-engine.test.ts b/packages/signal-engine/test/attendance-engine.test.ts index 257a588..9cd99f4 100644 --- a/packages/signal-engine/test/attendance-engine.test.ts +++ b/packages/signal-engine/test/attendance-engine.test.ts @@ -28,8 +28,7 @@ function attempt(id: number, submittedAt: Date): Attempt { isCorrect: true, answerGiven: { key: 'A' }, hintsUsed: 0, - source: 'fixture', - sourceEventId: `event-${id}`, + activityId: `event-${id}`, ingestedAt: submittedAt, }; } @@ -78,8 +77,8 @@ describe('attendance-aware disengagement', () => { { id: 1, studentId, - startDate: new Date('2026-07-21T00:00:00.000Z'), - endDate: new Date('2026-07-23T00:00:00.000Z'), + startDate: '2026-07-21', + endDate: '2026-07-23', source: 'fixture', }, ]); diff --git a/packages/signal-engine/test/board-window.test.ts b/packages/signal-engine/test/board-window.test.ts index fb3bf53..bd8182d 100644 --- a/packages/signal-engine/test/board-window.test.ts +++ b/packages/signal-engine/test/board-window.test.ts @@ -29,8 +29,8 @@ describe('Chicago half-open board windows', () => { }); it('normalizes inclusive date-only attendance through DST as civil days', () => { - const start = chicagoDateOnlyBoundary(new Date('2026-03-08T00:00:00.000Z')); - const end = chicagoDateOnlyBoundary(new Date('2026-03-08T00:00:00.000Z'), 1); + const start = chicagoDateOnlyBoundary('2026-03-08'); + const end = chicagoDateOnlyBoundary('2026-03-08', 1); expect(start.toISOString()).toBe('2026-03-08T06:00:00.000Z'); expect(end.toISOString()).toBe('2026-03-09T05:00:00.000Z'); expect(chicagoCalendarDaysBetween(start, end)).toBe(1); diff --git a/packages/signal-engine/test/determinism.test.ts b/packages/signal-engine/test/determinism.test.ts index 12f359c..bdd357e 100644 --- a/packages/signal-engine/test/determinism.test.ts +++ b/packages/signal-engine/test/determinism.test.ts @@ -72,7 +72,7 @@ describe('determinism', () => { severity: 0.8, confidence: 0.9, evidence: { - attemptSourceEventIds: ['evt-2'], + attemptActivityIds: ['evt-2'], summary: 'volume fell below the expected level', values: { expectedVolume: 8, dropFraction: 0.75 }, }, diff --git a/packages/signal-engine/test/disengagement-grain.test.ts b/packages/signal-engine/test/disengagement-grain.test.ts index e6b2a14..703f695 100644 --- a/packages/signal-engine/test/disengagement-grain.test.ts +++ b/packages/signal-engine/test/disengagement-grain.test.ts @@ -30,8 +30,7 @@ function attempt(id: number, studentId: string, skillId: string, submittedAt: Da isCorrect: true, answerGiven: { key: 'A' }, hintsUsed: 0, - source: 'fixture', - sourceEventId: `event-${studentId}-${id}`, + activityId: `event-${studentId}-${id}`, ingestedAt: submittedAt, }; } diff --git a/packages/signal-engine/test/grinding-session-engine.test.ts b/packages/signal-engine/test/grinding-session-engine.test.ts index c182530..db853e3 100644 --- a/packages/signal-engine/test/grinding-session-engine.test.ts +++ b/packages/signal-engine/test/grinding-session-engine.test.ts @@ -36,8 +36,7 @@ function sessionAttempt( isCorrect, answerGiven: { key: isCorrect ? 'A' : 'B' }, hintsUsed: 0, - source: 'fixture', - sourceEventId: `session-${id}`, + activityId: `session-${id}`, ingestedAt: new Date(submittedAt), sessionTotalMs, }; diff --git a/packages/signal-engine/test/half-open-engine.test.ts b/packages/signal-engine/test/half-open-engine.test.ts index 28ac442..79b8648 100644 --- a/packages/signal-engine/test/half-open-engine.test.ts +++ b/packages/signal-engine/test/half-open-engine.test.ts @@ -18,7 +18,7 @@ const countRule: RuleContract = { severity: 1, confidence: 1, evidence: { - attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), summary: 'in-window', values: {}, }, diff --git a/packages/signal-engine/test/helpers.ts b/packages/signal-engine/test/helpers.ts index b8b2a70..de06284 100644 --- a/packages/signal-engine/test/helpers.ts +++ b/packages/signal-engine/test/helpers.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import type { Attempt, Item, Skill, SkillPrereq } from '@huddle/core'; @@ -21,13 +22,19 @@ export function loadFixture(name: string): FixtureSet { const parsed = JSON.parse(text); return { name: parsed.name ?? name, - attempts: parsed.attempts.map((a: Record) => ({ - ...a, - startedAt: new Date(a.startedAt as string), - submittedAt: new Date(a.submittedAt as string), - ingestedAt: new Date((a.ingestedAt as string) ?? (a.submittedAt as string)), - timingWasWinsorized: (a.timingWasWinsorized as boolean | undefined) ?? false, - })) as Attempt[], + attempts: parsed.attempts.map((a: Record) => { + const { source, sourceEventId, ...attempt } = a; + return { + ...attempt, + activityId: createHash('sha256') + .update(JSON.stringify([source, sourceEventId])) + .digest('hex'), + startedAt: new Date(a.startedAt as string), + submittedAt: new Date(a.submittedAt as string), + ingestedAt: new Date((a.ingestedAt as string) ?? (a.submittedAt as string)), + timingWasWinsorized: (a.timingWasWinsorized as boolean | undefined) ?? false, + }; + }) as Attempt[], }; } 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 e207b3f..cdc896d 100644 --- a/packages/signal-engine/test/per-fired-rule-evidence.test.ts +++ b/packages/signal-engine/test/per-fired-rule-evidence.test.ts @@ -18,7 +18,7 @@ describe('per-fired-rule evidence identity', () => { type: 'fired' as const, severity: index ? 0.6 : 0.8, confidence: index ? 0.7 : 0.9, - evidence: { attemptSourceEventIds: ['evt-1'], summary: id, values: {} }, + evidence: { attemptActivityIds: ['evt-1'], summary: id, values: {} }, }), })); const context = makeContext({ attempts: [attempt({ id: 1 })] }); @@ -76,7 +76,7 @@ describe('per-fired-rule evidence identity', () => { severity: 0.8, confidence: 0.9, evidence: { - attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), summary: 'skill', values: {}, }, @@ -95,7 +95,7 @@ describe('per-fired-rule evidence identity', () => { severity: 0.7, confidence: 0.8, evidence: { - attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), summary: 'cross', values: {}, }, @@ -104,7 +104,7 @@ describe('per-fired-rule evidence identity', () => { ]; const run = (attemptId: number, activeRules = rules) => { const context = makeContext({ - attempts: [attempt({ id: attemptId, sourceEventId: 'stable-event' })], + attempts: [attempt({ id: attemptId, activityId: 'stable-event' })], }); return runEngine( { @@ -164,7 +164,7 @@ describe('per-fired-rule evidence identity', () => { ).toBe('v2'); }); - it('orders exact evidence by source identity and preserves item metadata', () => { + it('orders exact evidence by opaque activity identity and preserves item metadata', () => { const item: Item = { id: 'numeric:TEKS.4.3E-01', skillId: defaultSkill.id, @@ -186,7 +186,7 @@ describe('per-fired-rule evidence identity', () => { severity: 0.5, confidence: 1, evidence: { - attemptSourceEventIds: ctx.attempts.map((entry) => entry.sourceEventId), + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), summary: 'numeric', values: {}, }, @@ -197,13 +197,13 @@ describe('per-fired-rule evidence identity', () => { attempt({ id: ids[0], itemId: item.id, - sourceEventId: 'source-z', + activityId: 'f'.repeat(64), submittedAt: new Date('2026-07-22T08:01:00.000Z'), }), attempt({ id: ids[1], itemId: item.id, - sourceEventId: 'source-a', + activityId: 'a'.repeat(64), submittedAt: new Date('2026-07-22T08:01:00.000Z'), }), ]; @@ -228,7 +228,10 @@ describe('per-fired-rule evidence identity', () => { const first = run([null, null]); const bundle = first.evidence as import('@huddle/core').EvidenceBundle; - expect(bundle.attempts.map((entry) => entry.sourceEventId)).toEqual(['source-a', 'source-z']); + expect(bundle.attempts.map((entry) => entry.activityId)).toEqual([ + 'a'.repeat(64), + 'f'.repeat(64), + ]); expect(bundle.attempts.map((entry) => entry.attemptId)).toEqual([null, null]); expect( bundle.attempts.map(({ itemType, timingProfile }) => ({ itemType, timingProfile })) @@ -240,4 +243,56 @@ describe('per-fired-rule evidence identity', () => { const rekeyed = run([11, 10], true); expect(rekeyed.evidenceFingerprint).toBe(first.evidenceFingerprint); }); + + it('selects evidence by the opaque composite identity', () => { + const selectedActivityId = 'a'.repeat(64); + const otherActivityId = 'b'.repeat(64); + const rule: RuleContract = { + id: 'composite.exact', + version: 'v1', + emits: 'guessing', + family: 'answer-choice', + urgency: 0.5, + unit: 'student-skill', + requiredInputs: [], + evaluate: () => ({ + type: 'fired', + severity: 0.5, + confidence: 1, + evidence: { + attemptActivityIds: [selectedActivityId], + summary: 'one source event', + values: {}, + }, + }), + }; + const context = makeContext({ + attempts: [ + attempt({ id: 1, activityId: selectedActivityId }), + attempt({ id: 2, activityId: otherActivityId }), + ], + }); + const signal = runEngine( + { + students: [context.student], + skills: [defaultSkill], + attempts: [...context.attempts], + items: Object.values(defaultItems), + skillPrereqs: [], + mastery: context.mastery, + attendance: [], + attendanceLoaded: true, + config: context.config, + now: context.asOf, + window: context.window, + }, + [rule] + ).signals[0]!; + + expect( + (signal.evidence as import('@huddle/core').EvidenceBundle).attempts.map( + (entry) => entry.activityId + ) + ).toEqual([selectedActivityId]); + }); }); diff --git a/packages/signal-engine/test/rule-context.ts b/packages/signal-engine/test/rule-context.ts index c4244b3..712baab 100644 --- a/packages/signal-engine/test/rule-context.ts +++ b/packages/signal-engine/test/rule-context.ts @@ -84,8 +84,7 @@ export function attempt(props: Partial & { id: number | null }): Attemp isCorrect: true, answerGiven: { key: 'B' }, hintsUsed: 0, - source: 'fixture', - sourceEventId: `evt-${props.id}`, + activityId: `evt-${props.id}`, ingestedAt: new Date('2026-07-22T08:01:00Z'), ...props, } as Attempt; diff --git a/scripts/nightly.ts b/scripts/nightly.ts index f7a7d18..4a68549 100644 --- a/scripts/nightly.ts +++ b/scripts/nightly.ts @@ -1,4 +1,5 @@ import { pool, type PoolClient } from '@huddle/db/client.js'; +import { opaqueActivityId } from '@huddle/db/activity-identity.js'; import { clearBoardForGuide, clearMasterySnapshotForGuide, @@ -128,11 +129,11 @@ async function loadAbsences(client: PoolClient, studentIds: string[]) { const { rows } = await client.query<{ id: number; student_id: string; - start_date: Date; - end_date: Date; + start_date: string; + end_date: string; attendance_source: string; }>( - 'SELECT id, student_id, start_date, end_date, source AS attendance_source FROM absence WHERE student_id = ANY($1::uuid[])', + 'SELECT id, student_id, start_date::text AS start_date, end_date::text AS end_date, source AS attendance_source FROM absence WHERE student_id = ANY($1::uuid[])', [studentIds] ); return rows.map( @@ -217,7 +218,7 @@ async function loadAttempts( `SELECT id, student_id, skill_id, item_id, session_id, attempt_index, started_at, submitted_at, elapsed_ms, engaged_ms, session_total_ms, timing_quality, timing_was_winsorized, is_correct, answer_given, hints_used, - source_event_id, ingested_at + source, source_event_id, ingested_at FROM attempt WHERE student_id = ANY($1::uuid[]) AND submitted_at >= $2 @@ -229,6 +230,7 @@ async function loadAttempts( (r) => ({ id: Number(r.id), + activityId: opaqueActivityId(r.source, r.source_event_id), studentId: r.student_id, skillId: r.skill_id, itemId: r.item_id, @@ -244,9 +246,6 @@ async function loadAttempts( isCorrect: r.is_correct, answerGiven: parseAnswerGiven(r.answer_given), hintsUsed: Number(r.hints_used), - // Source is intentionally quarantined at ingest; detection receives no platform identity. - source: 'quarantined', - sourceEventId: r.source_event_id, ingestedAt: r.ingested_at, }) as Attempt ); diff --git a/specs/001-huddle-triage-board/compliance-report.md b/specs/001-huddle-triage-board/compliance-report.md index 6d7a9c2..50711f4 100644 --- a/specs/001-huddle-triage-board/compliance-report.md +++ b/specs/001-huddle-triage-board/compliance-report.md @@ -150,7 +150,7 @@ the divergence count. | FR-017 confidence first-class | `RuleOutcome.fired.confidence`; `confidence.ts` | full_match | 0.95 | | FR-018 / invariant 4 winsorization | `winsorize.ts:22-30` correct; `default` bound unreachable at :11; never invoked in any live path | **partial_match** | 0.90 | | FR-019 / Principle VII relative-to-self | All baselines are per-student; no cohort statistic anywhere in `signal-engine` | full_match | 0.95 | -| FR-020 record what fired the rule | `Evidence.attemptSourceEventIds` + `values` on every rule | full_match | 0.95 | +| FR-020 record what fired the rule | `Evidence.attemptActivityIds` + `values` on every rule | full_match | 0.95 | | FR-021 conflict ⇒ reduce confidence + disclose | `engine.ts:117-133`; `confidence.ts:52` | full_match | 0.90 | | FR-022 self-contained bundle | `evidence.ts:36-153` assembles it correctly… | full_match | 0.95 | | FR-023/FR-024 generation sees only the bundle | …`narrator/src/prompt.ts:23` then strips it to almost nothing | **mismatch** | 0.98 | diff --git a/specs/001-huddle-triage-board/contracts/evidence-bundle.md b/specs/001-huddle-triage-board/contracts/evidence-bundle.md index 2115975..e99eb53 100644 --- a/specs/001-huddle-triage-board/contracts/evidence-bundle.md +++ b/specs/001-huddle-triage-board/contracts/evidence-bundle.md @@ -39,7 +39,7 @@ export interface EvidenceBundle { }; attempts: Array<{ attemptId: number | null; // persistence surrogate for drill-down only; never semantic identity - sourceEventId: string; // stable activity identity used for selection, ordering, and fingerprints + activityId: string; // opaque hash of source plus source event ID ordinal: number; skill: { code: string; name: string }; // required even for cross-skill bundles itemType: ItemType; From 6fc9257767ee5bf02bc397061b5b0edaaa658a7f Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 13:38:39 -0500 Subject: [PATCH 5/7] no-mistakes(document): Align shared-foundation documentation and lint --- packages/core/src/fixtures/loader.ts | 1 + packages/db/src/scoped.ts | 6 ++-- scripts/nightly.ts | 1 + .../checklists/requirements.md | 8 ++--- .../compliance-report.md | 2 +- .../contracts/eval-harness.md | 2 +- .../contracts/ingest-adapter.md | 17 ++++++---- .../contracts/rule-contract.md | 2 +- specs/001-huddle-triage-board/data-model.md | 33 +++++++++++-------- specs/001-huddle-triage-board/plan.md | 25 +++++++------- specs/001-huddle-triage-board/quickstart.md | 5 +-- specs/001-huddle-triage-board/research.md | 15 +++++---- specs/001-huddle-triage-board/tasks.md | 31 +++++++++-------- 13 files changed, 83 insertions(+), 65 deletions(-) diff --git a/packages/core/src/fixtures/loader.ts b/packages/core/src/fixtures/loader.ts index 93921cc..001fdc6 100644 --- a/packages/core/src/fixtures/loader.ts +++ b/packages/core/src/fixtures/loader.ts @@ -78,6 +78,7 @@ export async function loadFixture(name: string): Promise { dataset: 'synthetic-test', name: parsed.name ?? name, attempts: parsed.attempts.map((a: Record) => { + // eslint-disable-next-line no-restricted-syntax -- Synthetic fixture provenance is hashed opaquely, never interpreted. const { source, sourceEventId, ...attempt } = a; return { ...attempt, diff --git a/packages/db/src/scoped.ts b/packages/db/src/scoped.ts index 17a7d9a..80ef78f 100644 --- a/packages/db/src/scoped.ts +++ b/packages/db/src/scoped.ts @@ -168,9 +168,9 @@ export async function clearBoardForGuide( AND student_id IN (SELECT id FROM student WHERE guide_id = $2)`, [boardDate, guideId] ); - // window_end is a timestamptz that nightly anchors to the UTC end of the board date. - // Casting it to date would resolve against the server's session TimeZone, so an - // eastward server would match nothing and leave the previous run's signals orphaned. + // The legacy nightly path anchors window_end to Chicago midnight, represented as a UTC instant. + // Casting it to date would resolve against the server's session TimeZone and could leave the + // previous run's signals orphaned. await client.query( `DELETE FROM signal WHERE student_id IN (SELECT id FROM student WHERE guide_id = $1) diff --git a/scripts/nightly.ts b/scripts/nightly.ts index 4a68549..bb58aec 100644 --- a/scripts/nightly.ts +++ b/scripts/nightly.ts @@ -230,6 +230,7 @@ async function loadAttempts( (r) => ({ id: Number(r.id), + // eslint-disable-next-line no-restricted-syntax -- Legacy composition hashes opaque provenance without branching on it. activityId: opaqueActivityId(r.source, r.source_event_id), studentId: r.student_id, skillId: r.skill_id, diff --git a/specs/001-huddle-triage-board/checklists/requirements.md b/specs/001-huddle-triage-board/checklists/requirements.md index 289faa5..5a41f55 100644 --- a/specs/001-huddle-triage-board/checklists/requirements.md +++ b/specs/001-huddle-triage-board/checklists/requirements.md @@ -133,9 +133,9 @@ The new traceability table in `tasks.md` maps FR-052–FR-061 and SC-012–SC-01 fields, owners, named acceptance tests, and `verify:guide-flow` cases. Every promised quickstart command remains subject to T002a/T002b/T111/T136. -Three non-clarification future gates remain explicit rather than guessed: external eval release -promotion/regression authority, severity governance, and real-data retention. They do not block this -synthetic specification review, but they block the uses named in `spec.md`; constitutional merge -gates remain non-waivable. +The captain-approved portfolio gate permits only the synthetic no-accuracy-claim use named in +`spec.md`. Accuracy-regression authority, severity governance, and real-data retention remain +explicit rather than guessed and block their named pilot/accuracy/real-data uses; constitutional +merge gates remain non-waivable. Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`. diff --git a/specs/001-huddle-triage-board/compliance-report.md b/specs/001-huddle-triage-board/compliance-report.md index 50711f4..fc9cf53 100644 --- a/specs/001-huddle-triage-board/compliance-report.md +++ b/specs/001-huddle-triage-board/compliance-report.md @@ -150,7 +150,7 @@ the divergence count. | FR-017 confidence first-class | `RuleOutcome.fired.confidence`; `confidence.ts` | full_match | 0.95 | | FR-018 / invariant 4 winsorization | `winsorize.ts:22-30` correct; `default` bound unreachable at :11; never invoked in any live path | **partial_match** | 0.90 | | FR-019 / Principle VII relative-to-self | All baselines are per-student; no cohort statistic anywhere in `signal-engine` | full_match | 0.95 | -| FR-020 record what fired the rule | `Evidence.attemptActivityIds` + `values` on every rule | full_match | 0.95 | +| FR-020 record what fired the rule | `Evidence.attemptIds` + `values` on every rule | full_match | 0.95 | | FR-021 conflict ⇒ reduce confidence + disclose | `engine.ts:117-133`; `confidence.ts:52` | full_match | 0.90 | | FR-022 self-contained bundle | `evidence.ts:36-153` assembles it correctly… | full_match | 0.95 | | FR-023/FR-024 generation sees only the bundle | …`narrator/src/prompt.ts:23` then strips it to almost nothing | **mismatch** | 0.98 | diff --git a/specs/001-huddle-triage-board/contracts/eval-harness.md b/specs/001-huddle-triage-board/contracts/eval-harness.md index 17e0c44..c883f3a 100644 --- a/specs/001-huddle-triage-board/contracts/eval-harness.md +++ b/specs/001-huddle-triage-board/contracts/eval-harness.md @@ -112,7 +112,7 @@ CI behavior is fail-closed: detector code or seed-42 metrics are inspected. 2. Generate reference seeds and seeded PRNG. 3. Generate the minimal deterministic timing calibration corpus, independent of full simulator and - both eval splits; review and commit per-item-type winsorization bounds. + both eval splits; review and commit per-timing-profile winsorization bounds. 4. Implement ingest/rules. Tune detector thresholds only on seed 42; each change creates a new behavior fingerprint and is re-frozen. Holdout content and gate values remain unchanged. 5. Freeze the accepted detector config, then run untouched seed 1337 exactly for reporting and run diff --git a/specs/001-huddle-triage-board/contracts/ingest-adapter.md b/specs/001-huddle-triage-board/contracts/ingest-adapter.md index f617bd5..97bb379 100644 --- a/specs/001-huddle-triage-board/contracts/ingest-adapter.md +++ b/specs/001-huddle-triage-board/contracts/ingest-adapter.md @@ -99,8 +99,9 @@ For each detected delivery, `packages/ingest/src/pipeline.ts` performs exactly t 1. adapter `parse` to drafts and source parse failures; 2. attach source/version, `ctx.receivedAt`, and dedupe identities inside ingest; 3. map each `vendorSkill` through `adapter.mapSkill`; -4. resolve the vendor item to a seeded internal item, its response-semantic `item_type`, and its separate fingerprinted timing profile; -5. apply the frozen per-item-type winsorization bound exactly once; +4. resolve the vendor item to a seeded internal item, its response-semantic `item_type`, and its + separate fingerprinted timing profile; +5. apply the frozen per-timing-profile winsorization bound exactly once; 6. resolve the internal learning session and pseudonymous student; 7. call the injected persistence port; the composition root's `packages/db` implementation transactionally stores valid attempts and session aggregates; @@ -108,8 +109,8 @@ For each detected delivery, `packages/ingest/src/pipeline.ts` performs exactly t A mixed batch continues after an unmapped record; valid neighbors persist. Mapping may not guess an adjacent TEKS skill. Winsorization is not adapter-owned and is never recomputed from an ingest batch. -A successful mapping test proves the stored attempt carries the expected TEKS `skill_id` and resolved -`item_type`, not merely that a mapper returned a value. +A successful mapping test proves the stored attempt resolves the expected TEKS `skill_id`, +response-semantic `item_type`, and timing profile, not merely that a mapper returned a value. ## Session aggregate ownership @@ -149,7 +150,8 @@ rejected raw rows are not retained; bounded allow-listed normalized issue fields - `engagedMs` exists only with genuine client instrumentation. - `elapsedMs` exists only with genuine per-attempt wall-clock timing. - A source session total yields a session aggregate, not attempt durations. -- The pipeline nulls an out-of-bound duration and downgrades quality from the frozen item-type bound. +- The pipeline nulls an out-of-bound duration and downgrades quality from the frozen timing-profile + bound. - Emitted quality never exceeds `timingCapability`. - only `packages/ingest` may inspect or branch on `source`; an injected typed port carries its opaque value to `packages/db`, which may persist it but may not expose source-dependent reads or behavior; @@ -160,7 +162,7 @@ rejected raw rows are not retained; bounded allow-listed normalized issue fields | Guarantee | Planned executable proof | |---|---| | one row per source attempt | two source events in one session persist as two attempts; no aggregate attempt row | -| mapping success | vendor skill maps to the expected TEKS FK and item type | +| mapping success | vendor skill maps to the expected TEKS FK, item type, and timing profile | | unmapped retention | unknown skill persists once in `unmapped_activity`; valid mixed-batch rows still persist | | session honesty | one aggregate, unchanged total/count, attempt durations null, replay stable | | winsorization ownership | adapter output is untouched; pipeline transforms once; double application test fails | @@ -169,7 +171,8 @@ rejected raw rows are not retained; bounded allow-listed normalized issue fields | portability | second differently-shaped adapter changes only `packages/ingest` and fixtures | Reference fixtures cover clean engaged data, session-only data, no timing, duplicates, successful and -unknown mappings in one batch, malformed timestamps/durations, and an outlier around each item-type +unknown mappings in one batch, malformed timestamps/durations, and an outlier around each +timing-profile bound. The guide-facing quick-demo adapter is the bounded `synthetic-csv-v1` file contract in [`application-interfaces.md`](./application-interfaces.md): validate and commit are separate, accepted/duplicate/unmapped/rejected counts are durable, and no real-data payload is admitted. The diff --git a/specs/001-huddle-triage-board/contracts/rule-contract.md b/specs/001-huddle-triage-board/contracts/rule-contract.md index 20804bf..6d9d5c3 100644 --- a/specs/001-huddle-triage-board/contracts/rule-contract.md +++ b/specs/001-huddle-triage-board/contracts/rule-contract.md @@ -172,7 +172,7 @@ student, remaining findings are drill-down `additionalCauses` in the same order. - every threshold; - the urgency table; - this confidence/timing/winsorization/conflict policy and version; -- per-item-type winsorization bounds; +- per-timing-profile winsorization bounds; - mastery half-life and known-attempt floor; - personal-baseline definition and version; - timezone, daily-window, canonical-week, and scoring-snapshot policy. diff --git a/specs/001-huddle-triage-board/data-model.md b/specs/001-huddle-triage-board/data-model.md index 9a126a5..0984b9d 100644 --- a/specs/001-huddle-triage-board/data-model.md +++ b/specs/001-huddle-triage-board/data-model.md @@ -87,17 +87,22 @@ CREATE TABLE skill_prereq ( ); CREATE TYPE item_type AS ENUM ('multiple_choice', 'numeric', 'short_text'); +CREATE TYPE timing_profile AS ENUM ('standard_multiple_choice', 'word_problem'); CREATE TABLE item ( id text PRIMARY KEY, skill_id text NOT NULL REFERENCES skill(id), item_type item_type NOT NULL, + timing_profile timing_profile NOT NULL, difficulty real NOT NULL CHECK (difficulty BETWEEN 0 AND 1), choices jsonb NOT NULL ); ``` Reference skill/item rows load before any US1 fixture. A test proves every attempt fixture resolves a -real item, item type, and skill; item-type winsorization bounds therefore cannot be bypassed. +real item, response-semantic item type, timing profile, and skill. Winsorization is selected only by +the resolved timing profile, so item IDs and response semantics cannot bypass or silently choose a +timing bound. + The server verifies Supabase Auth, resolves exactly one synthetic `guide_auth_scope`, and constructs `GuideAccess`; no repository accepts browser-supplied guide/studio identity. Every student query joins through `student.guide_id` and checks the synthetic guide/student/scope flags. Missing, ambiguous, @@ -285,14 +290,14 @@ aggregate, the linked session has quality `none` and a null total; an unavailabl `vendor_attempt_count` remains null and is never inferred from accepted attempts. For `session_only`, the linked session has `session_only` quality and the exact positive source total/count while attempt durations are null. Rules that require a session mean abstain when either -source value is unavailable. An attempt's `item_type` is derived through its required `item_id` -foreign key and is not duplicated on the attempt row. Adapters emit drafts; the shared ingest -pipeline maps -`vendorSkill`, resolves item and `item_type`, winsorizes once, then calls an injected ingest-owned -persistence port whose sole implementation lives in `packages/db`. Ingest never imports the driver -or the DB package. Unknown skills create only an `unmapped_activity` row. Attempt-grain, successful -mapping, session replay/no-split, mixed-batch, guide-scope/digest, preview-no-publication, and -unmapped-dedupe integration tests are mandatory. +source value is unavailable. An attempt's response-semantic `item_type` and separate +`timing_profile` are derived through its required `item_id` foreign key and are not duplicated on the +attempt row. Adapters emit drafts; the shared ingest pipeline maps +`vendorSkill`, resolves the item, type, and timing profile, winsorizes once, then calls an injected +ingest-owned persistence port whose sole implementation lives in `packages/db`. Ingest never imports +the driver or the DB package. Unknown skills create only an `unmapped_activity` row. Attempt-grain, +successful mapping, session replay/no-split, mixed-batch, guide-scope/digest, +preview-no-publication, and unmapped-dedupe integration tests are mandatory. ## Mastery @@ -311,7 +316,7 @@ LANGUAGE sql STABLE AS $$ / nullif(sum(exp(-ln(2) * extract(epoch FROM (anchor-a.submitted_at)) / 86400.0 / 14.0)), 0))::real, count(*), count(*) >= 5 FROM attempt a - WHERE a.submitted_at <= anchor + WHERE a.submitted_at < anchor GROUP BY a.student_id, a.skill_id; $$; @@ -671,9 +676,9 @@ appear. `confidence_breakdown` stores exact timing, winsorization, and conflict multipliers whose product with `raw_confidence` equals `final_confidence`. `behavior_fingerprint` is SHA-256 of canonical JSON over -ordered rule IDs/versions, thresholds, urgency, confidence policy, item-type winsor bounds, mastery -constants, baseline definition/version, and calendar/window/scoring policy. Tests mutate each -component and require a different fingerprint. +ordered rule IDs/versions, thresholds, urgency, confidence policy, timing-profile winsor bounds, +mastery constants, baseline definition/version, and calendar/window/scoring policy. Tests mutate +each component and require a different fingerprint. ## Weekly truth and evaluation artifacts @@ -762,7 +767,7 @@ row exists. | attempt grain and scope/student source dedupe | two-event/multi-attempt + cross-scope collision test | | session total stored once, never split | scoped composite FK/schema + session-only/replay test | | unmapped retained and reportable | append-only table + mixed-batch/dedupe test | -| vendor skill maps to TEKS/item type | mapping persistence integration test | +| vendor skill maps to TEKS/item type/timing profile | mapping persistence integration test | | mastery immutable/derived | static no-UPDATE/no-mutation test + equivalence test | | source semantics only in ingest; opaque DB persistence only | restricted branch/member gate + repository API test | | DB driver only in `packages/db`; core/application pure | negative direct/transitive dependency fixtures | diff --git a/specs/001-huddle-triage-board/plan.md b/specs/001-huddle-triage-board/plan.md index 5d43e13..abfa503 100644 --- a/specs/001-huddle-triage-board/plan.md +++ b/specs/001-huddle-triage-board/plan.md @@ -2,8 +2,8 @@ **Branch**: `001-huddle-triage-board` | **Date**: 2026-07-27 | **Spec**: [spec.md](./spec.md) -**Status**: Ready for Review — focused quick-demo contract delta complete; implementation tasks remain -unchecked and product code is out of scope for this commit. +**Status**: In implementation — bounded shared executable foundation landed; Evidence Desk, +Operations pipelines, model runtime, deployment, and the full simulator/eval remain out of scope. **Input**: Feature specification from `/specs/001-huddle-triage-board/spec.md` @@ -45,8 +45,9 @@ Huddle's dedicated data-access package, and is unreachable from `core`, `applica `signal-engine`, `ingest`, and `narrator` **Storage**: Supabase Postgres (PostgreSQL 16) — attempt-grain events; source-session aggregates -stored once; append-only unmapped activity; synthetic guide/Auth scope; item type, skill graph, -roster, recorded absence, import receipts, immutable board runs plus an atomic `board_head`, refresh +stored once; append-only unmapped activity; synthetic guide/Auth scope; item response type and +timing profile; skill graph, roster, recorded absence, import receipts, immutable board runs plus an +atomic `board_head`, refresh requests, signals, report acknowledgments, and catalog selections; mastery as `mastery_at(anchor)` plus a run-keyed `mastery_snapshot` (R7). Board deterministic fields are immutable; only validated narration/provenance may attach asynchronously without changing rank, causes, evidence, scope, or @@ -235,17 +236,17 @@ Merged PR #2 (`9dc16a5`) already establishes Supabase public Auth sign-in, serve verification, synthetic `guide_auth_scope`, server-only Postgres reads through `@huddle/db`, and revoked browser Data API privileges. The repository physically names the dedicated data-access package `packages/db`; this delta uses that path rather than proposing a no-value rename. The current -table board, destructive date-keyed refresh, UTC end-of-day anchor, raw narration strings, and -missing import/evidence/acknowledgment workflows are implementation gaps, not alternate contracts. -Tasks below evolve those seams toward the approved Evidence Desk and immutable publication model. +table board, destructive date-keyed refresh, raw narration strings, and missing +import/evidence/acknowledgment workflows are implementation gaps, not alternate contracts. Tasks +below evolve those seams toward the approved Evidence Desk and immutable publication model. ### Future gates preserved -Constitutional merge gates remain non-waivable. Three product/operational decisions are deliberately -not invented here: external demo/pilot evaluation-release promotion and regression approver; -severity-table governance/override authority; and real-data retention/deletion/backup/incident -policy. No external accuracy claim, governed pilot severity policy, real-data admission, or -multi-guide pilot may proceed until its corresponding captain/safety gate closes. +Constitutional merge gates remain non-waivable. The captain-approved portfolio gate permits only a +synthetic hosted work sample with executable grounding/fallback regressions and no diagnosis-accuracy +claim; the full accuracy bundle still gates every pilot or accuracy-backed claim. Accuracy-regression +approval, severity-table governance/override authority, and real-data +retention/deletion/backup/incident policy remain deliberately unresolved. ## Complexity Tracking diff --git a/specs/001-huddle-triage-board/quickstart.md b/specs/001-huddle-triage-board/quickstart.md index 9bf965b..8037e3b 100644 --- a/specs/001-huddle-triage-board/quickstart.md +++ b/specs/001-huddle-triage-board/quickstart.md @@ -77,7 +77,7 @@ npm run calibrate:winsorization -- --help npm run calibrate:winsorization -- --verify-committed ``` -Expected: the minimal item-type/PRNG corpus regenerates byte-identically and its 99th-percentile +Expected: the minimal timing-profile/PRNG corpus regenerates byte-identically and its 99th-percentile bounds match the reviewed committed artifact. Ingest and detector work must not start if they differ. ## 2. Synthetic determinism @@ -283,7 +283,8 @@ npm run test:idempotency Expected: fixture-B changes only ingest/fixtures. Three replays produce stable attempt, session, and unmapped counts, while reused source IDs in another application scope/student do not collide. -Successful vendor skill mapping persists the expected TEKS FK and item type. An +Successful vendor skill mapping persists the expected TEKS FK and resolves the expected item type and +timing profile. An unknown skill persists once in `unmapped_activity` while valid mixed-batch attempts persist. A session total/count is stored once and attempt durations stay null; winsorization runs once in the pipeline. diff --git a/specs/001-huddle-triage-board/research.md b/specs/001-huddle-triage-board/research.md index e962385..8a38372 100644 --- a/specs/001-huddle-triage-board/research.md +++ b/specs/001-huddle-triage-board/research.md @@ -200,9 +200,10 @@ exposes the session aggregate and nulls the per-attempt values, and rules that w what keeps `session_only` and `none` from producing identical results — without it, every timing rule abstains at both tiers and the four-point degradation curve collapses to three. -Winsorization bound is set per item type from a **minimal deterministic calibration corpus** produced -after reference seeds and the seeded PRNG but before ingest or detector rules. It is independent of -the full simulator and both tune/report corpora, generated and reviewed once, and committed as +Winsorization bounds are set per timing profile from a **minimal deterministic calibration corpus** +produced after reference seeds and the seeded PRNG but before ingest or detector rules. Response +semantics (`itemType`) do not select timing policy. The corpus is independent of the full simulator +and both tune/report corpora, generated and reviewed once, and committed as `packages/core/src/seed/winsorization-bounds.ts`. It is never derived from the batch being ingested: a batch-relative percentile winsorizes the same record differently depending on delivery, which would break determinism. Anything beyond the bound becomes `null` with @@ -457,6 +458,8 @@ Guide import accepts only the bounded `synthetic-csv-v1` contract and separates Results persist accepted/duplicate/unmapped/rejected counts. Refresh, import, and acknowledgment create no notifications. -Three decisions remain deliberately open rather than guessed: external evaluation-release promotion -and regression approver; severity-table governance/override authority; and real-data -retention/deletion/backup/incident policy. Constitutional merge gates remain mandatory regardless. +The portfolio work sample follows the captain-approved no-accuracy-claim gate in +[`contracts/eval-harness.md`](./contracts/eval-harness.md). Accuracy-regression approval, +severity-table governance/override authority, and real-data +retention/deletion/backup/incident policy remain deliberately open. Constitutional merge gates +remain mandatory regardless. diff --git a/specs/001-huddle-triage-board/tasks.md b/specs/001-huddle-triage-board/tasks.md index b877ab2..254a11a 100644 --- a/specs/001-huddle-triage-board/tasks.md +++ b/specs/001-huddle-triage-board/tasks.md @@ -6,8 +6,8 @@ description: "Task list for Huddle — Morning Triage Board" **Input**: all artifacts in `/specs/001-huddle-triage-board/` -**Tests**: mandatory. This Slice 0 revision closes contracts only; every unchecked task is future -application work. No executable constitution gate exists on this documentation branch. +**Tests**: mandatory. Checked items have landed; every unchecked task remains future application +work. The shared foundation does not claim that the full constitution gates or product flows exist. **Revision 4 (focused quick-demo delta, 2026-07-28 decisions)**: Revision 3 remains authoritative for diagnosis/evaluation. This delta aligns the task graph to current `packages/db`/Supabase foundations @@ -60,7 +60,7 @@ db/migrations/ forward-only SQL, owned by packag DB/model/simulator transitively; narrator has no DB; eval scoring has no model; ingest cannot import `packages/db`/driver; only ingest may inspect/branch on source while `packages/db` may persist it opaquely through the injected port; browser code cannot import DB/server implementations. -- [ ] T112 Scaffold `packages/application` with the exact `BoardReader`, `EvidenceReader`, +- [x] T112 Scaffold `packages/application` with the exact `BoardReader`, `EvidenceReader`, `BoardCompiler`, `Importer`, access, board, refresh, import, evidence, narration, and acknowledgment DTO/command contracts from `contracts/application-interfaces.md`; tests reject DB rows/generic CRUD and framework, Supabase, model, or driver imports. @@ -70,8 +70,9 @@ db/migrations/ forward-only SQL, owned by packag **Order is mandatory**: workspace → reference seeds + PRNG → minimal calibration corpus → reviewed bounds → ingest/rules. Full simulator tune/report data cannot generate calibration bounds. -- [ ] T009 Add/forward-migrate base guide/student/absence, skill/prereq, `item_type`, and item; - load through `packages/db/src/migrate.ts`. T113 solely owns synthetic flags/Auth scope. +- [ ] T009 Add/forward-migrate base guide/student/absence, skill/prereq, `item_type`, + `timing_profile`, and item; load through `packages/db/src/migrate.ts`. T113 solely owns synthetic + flags/Auth scope. - [ ] T113 Forward migration for `guide.is_synthetic`, `student.is_synthetic`, and an ID-backed, one-studio-per-guide `guide_auth_scope` with existing mapping backfill; revoke direct/default public-schema Data API privileges from `PUBLIC`, `anon`, and `authenticated` while recording RLS @@ -102,12 +103,13 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra run IDs, validate scope/count parity, then remove old date keys/uniqueness. - [ ] T012a Static/integration test in `packages/db/test/mastery-immutable.test.ts`: no `UPDATE mastery_snapshot`, no direct-edit repository/API, recomputation only from attempts. -- [ ] T013 [P] Pure domain types in `packages/core/src/types.ts`, including scope, session, item type, - unmapped/import run, refresh/head/run, finding acknowledgment, and narration selection/provenance. +- [ ] T013 [P] Pure domain types in `packages/core/src/types.ts`, including scope, session, response + item type, timing profile, unmapped/import run, refresh/head/run, finding acknowledgment, and + narration selection/provenance. - [ ] T014 [P] Matching Zod schemas in `packages/core/src/schemas.ts`. - [ ] T015 [P] Shared deterministic normalization/canonical JSON in `packages/core/src/normalize.ts`. - [ ] T016 [P] Grade 4 mathematics TEKS/prerequisite seed in `packages/core/src/seed/teks-grade4-math.ts`. -- [ ] T017 [P] Item bank with `itemType`, difficulty, distractor/misconception map in +- [ ] T017 [P] Item bank with `itemType`, `timingProfile`, difficulty, distractor/misconception map in `packages/core/src/seed/item-bank.ts`. - [ ] T017a Reference loader in `packages/db/src/reference.ts`; test proves skills/items load before any US1 fixture and all fixture references resolve. @@ -125,11 +127,11 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra - [ ] T021 Test PRNG byte determinism and student isolation in `packages/simulator/test/prng.test.ts`. - [ ] T021a Minimal timing calibration generator in `packages/simulator/src/calibration.ts`, depending - only on reference item types and PRNG, not latent traits/full simulator/tune/report seeds. + only on reference timing profiles and PRNG, not latent traits/full simulator/tune/report seeds. - [ ] T021b Test calibration byte stability and separation from seeds 42/1337 in `packages/simulator/test/calibration.test.ts`. - [ ] T018b Run `scripts/calibrate-timing.ts`; review and commit generated 99th-percentile bounds per - item type in `packages/core/src/seed/winsorization-bounds.ts`. + timing profile in `packages/core/src/seed/winsorization-bounds.ts`. - [ ] T086 [P] [US4] Author the independent holdout under `packages/eval/fixtures/holdout/` and freeze its content before seed-42 tuning or detector implementation; later phases may run but not edit it. - [ ] T019 Pipeline winsorization primitive in `packages/ingest/src/winsorize.ts`, nulling outliers @@ -502,9 +504,9 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra - [ ] T103 [US6] Reference adapter in `packages/ingest/src/adapters/reference/index.ts`. - [ ] T104 [P] [US6] Clean/session/no-timing/duplicate/mapped+unmapped/malformed/outlier fixtures under `packages/ingest/fixtures/reference/`. -- [ ] T105 [US6] Pipeline parse → map → item/type resolve → winsorize once → injected-port - persist/report in `packages/ingest/src/pipeline.ts`; a script composition root supplies the - `packages/db` persistence implementation. +- [ ] T105 [US6] Pipeline parse → map → item/type/timing-profile resolve → winsorize once → + injected-port persist/report in `packages/ingest/src/pipeline.ts`; a script composition root + supplies the `packages/db` persistence implementation. - [ ] T106 [P] [US6] Deliberately differently-shaped fixture-B adapter in `packages/ingest/src/adapters/fixture-b/index.ts`. @@ -560,7 +562,8 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra - Generated narration remains downstream. Deterministic cause-specific fallback is part of board publication; no model call or DB handle is reachable from deterministic detection/ranking. - Parallel rule work starts only after contract, severity, confidence, fingerprint, and engine land. -- External eval release promotion, severity governance, and real-data retention remain future gates; +- The captain-approved portfolio gate in `contracts/eval-harness.md` permits no accuracy claim; + accuracy-regression approval, severity governance, and real-data retention remain future gates. T134 records and enforces non-admission without inventing an owner or policy. ## Focused delta traceability From 26b79f4162d20c21bcdcc4bbd16274e7c58a3d4e Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 13:43:16 -0500 Subject: [PATCH 6/7] no-mistakes: apply CI fixes --- packages/signal-engine/src/engine.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index ed39b45..943f78f 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -22,7 +22,11 @@ import type { SkillGraphView, } from './contract.js'; import { assembleEvidenceBundle, type Conflict } from './evidence.js'; -import { stableStringify } from './config/thresholds.js'; +import { + RULE_VERSION, + ruleVersionForConfig, + stableStringify, +} from './config/thresholds.js'; import { resolvedAnswerChoices } from './answer-choices.js'; import { chicagoCalendarDaysBetween, @@ -120,6 +124,7 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp const classifications: StudentSkillClassification[] = []; const pendingSignals: PendingSignal[] = []; const behaviorFingerprint = behaviorFingerprintForRules(input.config, rules); + const configuredRuleVersion = ruleVersionForConfig(input.config); const sharedContext = { window: input.window, @@ -230,7 +235,8 @@ export function runEngine(input: EngineInput, rules: RuleContract[]): EngineOutp behaviorFingerprint, evidenceFingerprint: undefined, evidence: {}, - ruleVersion: record.rule.version, + ruleVersion: + record.rule.version === RULE_VERSION ? configuredRuleVersion : record.rule.version, windowStart: record.ctx.window.start, windowEnd: record.ctx.window.end, computedAt: input.now, From 40c2775ccb93c45191a79539b85c6a04b60dbddc Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 13:45:30 -0500 Subject: [PATCH 7/7] no-mistakes: apply CI fixes --- packages/signal-engine/src/engine.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/signal-engine/src/engine.ts b/packages/signal-engine/src/engine.ts index 943f78f..6443170 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -22,11 +22,7 @@ import type { SkillGraphView, } from './contract.js'; import { assembleEvidenceBundle, type Conflict } from './evidence.js'; -import { - RULE_VERSION, - ruleVersionForConfig, - stableStringify, -} from './config/thresholds.js'; +import { RULE_VERSION, ruleVersionForConfig, stableStringify } from './config/thresholds.js'; import { resolvedAnswerChoices } from './answer-choices.js'; import { chicagoCalendarDaysBetween,