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..55160a9 --- /dev/null +++ b/db/migrations/007_shared_foundation.sql @@ -0,0 +1,41 @@ +-- 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')); +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 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. +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/fixtures/loader.ts b/packages/core/src/fixtures/loader.ts index 6eea29d..001fdc6 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,20 @@ 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) => { + // eslint-disable-next-line no-restricted-syntax -- Synthetic fixture provenance is hashed opaquely, never interpreted. + 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/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/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/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..1fa5ed6 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,54 +36,52 @@ 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; - startDate: Date; - endDate: Date; + startDate: string; + endDate: string; 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,9 @@ export interface NormalizedAttempt { answerGiven: AnswerChoice | null; hintsUsed: number; } - export interface Attempt { - id: number | null; // bigserial, null before persistence + id: number | null; + activityId: string; studentId: string; skillId: string; itemId: string; @@ -110,50 +113,60 @@ 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; isCorrect: boolean; answerGiven: AnswerChoice | null; hintsUsed: number; - source: string; - sourceEventId: string; 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 +175,78 @@ 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; + attemptId: number | null; + activityId: string; ordinal: number; - date: string; + skill: { code: string; name: string }; + itemType: ItemType; + timingProfile: TimingProfile; + 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 +260,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..0965bca 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -19,6 +19,30 @@ "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" + }, + "./activity-identity.js": { + "import": "./dist/src/activity-identity.js", + "types": "./dist/src/activity-identity.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/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/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/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/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..80ef78f 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 `, @@ -167,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/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/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..056552c --- /dev/null +++ b/packages/db/test/shared-foundation-migration.test.ts @@ -0,0 +1,24 @@ +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 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/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..31f7b93 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,22 @@ 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 +): 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 = 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..263b97e 100644 --- a/packages/ingest/test/winsorize.test.ts +++ b/packages/ingest/test/winsorize.test.ts @@ -24,42 +24,36 @@ 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 })); - + const bound = WINSORIZATION_BOUNDS.standard_multiple_choice; + const result = winsorize( + attempt({ elapsedMs: bound, engagedMs: bound + 1 }), + 'standard_multiple_choice' + ); 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 result = winsorize( - attempt({ elapsedMs: WINSORIZATION_BOUNDS.mcq, engagedMs: null, timingQuality: 'wallclock' }) + it('rejects an unresolved timing profile', () => { + expect(() => winsorize(attempt(), undefined as never)).toThrow( + 'A resolved timing profile is required' ); - expect(result.elapsedMs).toBe(WINSORIZATION_BOUNDS.mcq); - 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..ae84c4d 100644 --- a/packages/narrator/test/request-contract.test.ts +++ b/packages/narrator/test/request-contract.test.ts @@ -10,25 +10,38 @@ 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, + activityId: 'event-1', ordinal: 1, - date: '2026-07-27', + skill: { code: 'TEKS.4.3A', name: 'Unit fractions' }, + itemType: 'multiple_choice', + timingProfile: 'standard_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 +52,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 +67,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..e710dfa 100644 --- a/packages/signal-engine/src/confidence.ts +++ b/packages/signal-engine/src/confidence.ts @@ -1,9 +1,36 @@ -import type { Attempt, TimingQuality } from '@huddle/core'; -import type { RuleConfig } from './contract.js'; +import type { Attempt, ConfidenceBreakdown, TimingQuality } from '@huddle/core'; +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,23 +42,47 @@ 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[], + requiredInputs: readonly InputRequirement[], + hasConflict: boolean, + config: RuleConfig +): ConfidenceBreakdown { + const requirement = timingRequirement(requiredInputs); + const timingMultiplier = requirement + ? config.confidence.tierFactors[worstUsableTimingQuality(attempts, requirement)] + : 1; + return { + timingMultiplier, + 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 { - 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, requiredInputs, false, 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( @@ -41,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..aeec4bf 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[]; + attemptActivityIds: 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; @@ -90,7 +97,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 18f1e53..6443170 100644 --- a/packages/signal-engine/src/engine.ts +++ b/packages/signal-engine/src/engine.ts @@ -1,27 +1,34 @@ +import { createHash } from 'node:crypto'; +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, applyConflictPenalty } 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 { ruleVersionForConfig } from './config/thresholds.js'; +import { assembleEvidenceBundle, type Conflict } from './evidence.js'; +import { RULE_VERSION, ruleVersionForConfig, stableStringify } from './config/thresholds.js'; import { resolvedAnswerChoices } from './answer-choices.js'; +import { + chicagoCalendarDaysBetween, + chicagoDateOnlyBoundary, + isInHalfOpenWindow, +} from './windows.js'; export interface EngineInput { students: { id: string; firstName: string }[]; @@ -57,10 +64,31 @@ 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) { - 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); @@ -75,8 +103,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); @@ -89,9 +117,10 @@ 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 configuredRuleVersion = ruleVersionForConfig(input.config); const sharedContext = { window: input.window, @@ -101,16 +130,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); - if (evaluated.signal) { - signals.push(evaluated.signal); - nextSignalId++; - } + pendingSignals.push(...evaluated.pendingSignals); }; if (perSkillRules.length > 0) { @@ -158,14 +184,101 @@ 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: + record.rule.version === RULE_VERSION ? configuredRuleVersion : record.rule.version, + 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[], - signalId: number -): { classification: StudentSkillClassification; signal: (Signal & { ruleId: string }) | null } { + rules: RuleContract[] +): { classification: StudentSkillClassification; pendingSignals: PendingSignal[] } { const ruleOutcomes: RuleEvaluation[] = []; for (const rule of rules) { const missing = findMissingInput(rule, ctx); @@ -176,20 +289,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 +315,93 @@ function evaluateContext( : 'clear', outcomes: ruleOutcomes, }; - if (fired.length === 0) return { classification, signal: null }; + if (fired.length === 0) return { classification, pendingSignals: [] }; - const scored = fired.map((result) => { - const relevant = relevantAttempts(result.ruleId, ctx.attempts); - const confidence = attenuateConfidence(result.outcome.confidence, relevant, ctx.config); - return { + 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, - confidence, - score: result.outcome.severity * confidence, - }; - }); - scored.sort( - (a, b) => b.score - a.score || b.severity - a.severity || a.ruleId.localeCompare(b.ruleId) + 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 }; +} + +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) ); +} - 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({ - 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); - } - } +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.attemptActivityIds); + return crossSkill.ctx.attempts.some( + (attempt) => attempt.skillId === skill && contributingIds.has(attempt.activityId) + ); +} - 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, +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), }; - signal.evidence = assembleEvidenceBundle(signal, evidenceCtx); - return { classification, signal }; + return createHash('sha256').update(stableStringify(canonicalEvidence)).digest('hex'); +} + +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 { @@ -334,34 +469,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. @@ -373,29 +493,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 2a08e43..1247af5 100644 --- a/packages/signal-engine/src/evidence.ts +++ b/packages/signal-engine/src/evidence.ts @@ -1,200 +1,206 @@ -import type { EvidenceBundle, EvidenceFamily, RootCause, Signal } from '@huddle/core'; +import type { + EvidenceBundle, + EvidenceFamily, + EvidenceScope, + RootCause, + Signal, +} from '@huddle/core'; import { MISCONCEPTIONS } from '@huddle/core/seed'; import { isWinsorizedOut } from './confidence.js'; import type { Evidence, RuleContext, RuleOutcome } from './contract.js'; import { perAttemptDurationMs } from './timing.js'; +import { chicagoCalendarDaysBetween } from './windows.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 contributingIds = new Set(firedEvidence.attemptActivityIds); 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; + .filter((attempt) => contributingIds.has(attempt.activityId)) + .sort( + (a, b) => + a.submittedAt.getTime() - b.submittedAt.getTime() || + a.activityId.localeCompare(b.activityId) + ); + 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]; + 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: 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, + activityId: attempt.activityId, + ordinal: index + 1, + skill: ctx.skill + ? { code: ctx.skill.id, name: ctx.skill.name } + : { code: attempt.skillId, name: attempt.skillId }, + itemType: item.itemType, + timingProfile: item.timingProfile, + 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: chicagoCalendarDaysBetween(signal.windowStart, signal.windowEnd), }, 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/index.ts b/packages/signal-engine/src/index.ts index a7d76c6..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 { 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/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/rules/decay.ts b/packages/signal-engine/src/rules/decay.ts index c5fce55..4183066 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 { @@ -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), + 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 d3dd0aa..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: { - attemptIds: ctx.attempts.map((attempt) => attempt.id ?? 0), + 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 5a4a575..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.id ?? 0) - (b.id ?? 0) + 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: { - attemptIds: matched.map((a) => a.id ?? 0), + 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 e31c726..5c6e268 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) { @@ -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 + attemptActivityIds: ctx.attempts .filter((attempt) => slowSessions.includes(attempt.sessionId)) - .map((attempt) => attempt.id ?? 0), + .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 4b620f1..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: { - attemptIds: slow.map((a) => a.id ?? 0), + 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 33591ef..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.id ?? 0) - (b.attempt.id ?? 0) + 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: { - attemptIds: wrong.map(({ attempt }) => attempt.id ?? 0), + 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 eb8fe4b..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: { - attemptIds: fastWrong.map((a) => a.id ?? 0), + 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 751729a..d1ac3f2 100644 --- a/packages/signal-engine/src/rules/guessing-inversion.ts +++ b/packages/signal-engine/src/rules/guessing-inversion.ts @@ -57,7 +57,7 @@ export const contract: RuleContract = { (wrongEasy.length + correctHard.length) / ctx.config.confidence.saturationCounts.inversion ), evidence: { - attemptIds: [...wrongEasy, ...correctHard].map((a) => a.id ?? 0), + 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 46ce7fd..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: { - attemptIds: wrong.map((attempt) => attempt.id ?? 0), + 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 68067d5..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: { - attemptIds: hintAttempts.map((a) => a.id ?? 0), + 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 487297a..3d2c0d3 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; @@ -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), + 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 0c57e0f..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: { - attemptIds: matching.map(({ attempt }) => attempt.id ?? 0), + 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 be614d2..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 bestAttemptIds: number[] = []; + 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.id ?? 0) - (b.attempt.id ?? 0) + 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; - bestAttemptIds = Array.from( - new Set(identicalPairs.flatMap((pair) => pair.map((attempt) => attempt.id ?? 0))) + 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: { - attemptIds: bestAttemptIds, + 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 a337f7d..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: { - attemptIds: fastRetries.map((a) => a.id ?? 0), + 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/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..b464baa 100644 --- a/packages/signal-engine/src/windows.ts +++ b/packages/signal-engine/src/windows.ts @@ -1,14 +1,96 @@ 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 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); + 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)); +} + +/** 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/attendance-engine.test.ts b/packages/signal-engine/test/attendance-engine.test.ts index 877d6ff..9cd99f4 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 { @@ -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, }; } @@ -40,7 +39,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 +49,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, @@ -69,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 1cf879f..bd8182d 100644 --- a/packages/signal-engine/test/board-window.test.ts +++ b/packages/signal-engine/test/board-window.test.ts @@ -1,30 +1,38 @@ 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, + chicagoCalendarDaysBetween, + chicagoDateOnlyBoundary, + 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'); +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); }); - 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); + it('normalizes inclusive date-only attendance through DST as civil days', () => { + 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/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/determinism.test.ts b/packages/signal-engine/test/determinism.test.ts index d017055..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: { - attemptIds: [2], + attemptActivityIds: ['evt-2'], summary: 'volume fell below the expected level', values: { expectedVolume: 8, dropFraction: 0.75 }, }, @@ -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/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 ddfdebd..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, }; @@ -80,17 +79,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 +139,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 new file mode 100644 index 0000000..79b8648 --- /dev/null +++ b/packages/signal-engine/test/half-open-engine.test.ts @@ -0,0 +1,69 @@ +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: ['skillMastery'], + evaluate: (ctx) => + ctx.attempts.length + ? { + type: 'fired', + severity: 1, + confidence: 1, + evidence: { + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), + 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 }, + asOf: end, + }); + const masteryAnchors: string[] = []; + const output = runEngine( + { + students: [context.student], + skills: [defaultSkill], + attempts: [...context.attempts], + items: Object.values(defaultItems), + skillPrereqs: [], + mastery: { + at: (_skillId, anchor) => { + masteryAnchors.push(anchor.toISOString()); + return { value: 0.8, isKnown: true as const }; + }, + }, + attendance: [], + attendanceLoaded: true, + config: context.config, + now: new Date('2026-03-09T18:00:00.000Z'), + 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); + expect(new Set(masteryAnchors)).toEqual(new Set([start.toISOString(), end.toISOString()])); + }); +}); 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 new file mode 100644 index 0000000..cdc896d --- /dev/null +++ b/packages/signal-engine/test/per-fired-rule-evidence.test.ts @@ -0,0 +1,298 @@ +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'; + +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: { attemptActivityIds: ['evt-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.asOf, + 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); + }); + + 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: { + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), + 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: { + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), + summary: 'cross', + values: {}, + }, + }), + }, + ]; + const run = (attemptId: number, activeRules = rules) => { + const context = makeContext({ + attempts: [attempt({ id: attemptId, activityId: 'stable-event' })], + }); + 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); + 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 opaque activity 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: { + attemptActivityIds: ctx.attempts.map((entry) => entry.activityId), + summary: 'numeric', + values: {}, + }, + }), + }; + const run = (ids: [number | null, number | null], reverse = false) => { + const attempts = [ + attempt({ + id: ids[0], + itemId: item.id, + activityId: 'f'.repeat(64), + submittedAt: new Date('2026-07-22T08:01:00.000Z'), + }), + attempt({ + id: ids[1], + itemId: item.id, + activityId: 'a'.repeat(64), + 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.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 })) + ).toEqual([ + { itemType: 'numeric', timingProfile: 'word_problem' }, + { itemType: 'numeric', timingProfile: 'word_problem' }, + ]); + + 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/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..712baab 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' }, @@ -36,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' }, @@ -58,12 +62,12 @@ export function makeContext(overrides: Partial = {}): RuleContext { attendance: [], attendanceLoaded: true, config: RULE_CONFIG, - now, + asOf, ...overrides, }; } -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', @@ -80,8 +84,7 @@ export function attempt(props: Partial & { id: number }): Attempt { 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/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/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/scripts/nightly.ts b/scripts/nightly.ts index 6ecfd13..bb58aec 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, @@ -17,6 +18,7 @@ import type { } from '@huddle/core'; import { allRules, + BOARD_TIMEZONE, boardWindows, rankBySeverity, rankedEvidenceBundle, @@ -56,11 +58,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) { @@ -101,9 +108,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 ); } @@ -112,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( @@ -201,11 +218,11 @@ 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 - AND submitted_at <= $3 + AND submitted_at < $3 ORDER BY submitted_at, id`, [studentIds, windowStart, windowEnd] ); @@ -213,6 +230,8 @@ 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, itemId: r.item_id, @@ -228,9 +247,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 ); @@ -306,8 +322,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 +354,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 +379,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 +397,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); 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/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/eval-harness.md b/specs/001-huddle-triage-board/contracts/eval-harness.md index 62c5fbb..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 @@ -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..e99eb53 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 + 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; + 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 diff --git a/specs/001-huddle-triage-board/contracts/ingest-adapter.md b/specs/001-huddle-triage-board/contracts/ingest-adapter.md index f200892..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 and its required `item_type`; -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 @@ -134,12 +135,23 @@ 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. - `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; @@ -150,7 +162,7 @@ records use the same reporting owner with an appropriate failure kind when a sta | 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 | @@ -159,7 +171,8 @@ records use the same reporting owner with an appropriate failure kind when a sta | 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/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..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 @@ -346,7 +348,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 @@ -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`. @@ -547,7 +549,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. @@ -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 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" },