/** * Core data model for psytools. * * Everything here is plain, JSON-serializable data. Scoring rules are * declarative (not functions) so that a full assessment definition survives * `JSON.stringify` -> database -> `JSON.parse` round trips without losing * behavior. */ /** * A piece of text in one or more locales, keyed by BCP-47 language tag * (e.g. `{ en: "Not at all", tr: "Hiç", de: "Überhaupt nicht" }`). */ export type LocalizedText = Record; /** One choice in an enumerated multiple-choice scale (e.g. a Likert point). */ export interface AssessmentOption { /** Numeric score contributed when this option is chosen. */ value: number; label: LocalizedText; } /** A single question (item) of an assessment. */ export interface AssessmentQuestion { /** Stable unique id within the assessment, e.g. `"phq9-3"`. */ id: string; text: LocalizedText; /** * Question-specific options. When omitted, the assessment-level `options` * scale applies (the common case for standardized inventories). */ options?: AssessmentOption[]; /** * When true, the answered value is reversed against the option scale * (max + min - value) before scoring. */ reverseScored?: boolean; /** When true, the question may be left unanswered at submit time. */ optional?: boolean; } /** A severity band: an inclusive score range with a localized label. */ export interface ScoreBand { /** Stable machine-readable id, e.g. `"moderate"`. */ id: string; /** Inclusive lower bound. */ min: number; /** Inclusive upper bound. */ max: number; label: LocalizedText; } /** * A clinical flag raised by a single item's answer, independent of the total * score — e.g. PHQ-9 item 9 (self-harm ideation) answered above 0. */ export interface ScoreFlag { /** Stable machine-readable id, e.g. `"suicidality"`. */ id: string; questionId: string; /** The flag is raised when the (raw) answer value is >= this. */ minValue: number; label: LocalizedText; } /** How a scorer treats unanswered items among the ones it uses. */ export type MissingDataStrategy = "ignore" | "prorate" | "require-complete"; /** * Missing-data policy for declarative scoring. Applies when incomplete * answers reach the scorer (raw answer maps, or `allowIncomplete`). * * - `ignore` (default): unanswered items contribute nothing — partial * totals understate severity, so use deliberately. * - `prorate`: the raw score is scaled up by totalItems / answeredItems * and rounded before any multiplier, per scale/subscale. * - `require-complete`: evaluation throws `incomplete_response` when any * contributing item is unanswered. */ export interface MissingDataPolicy { strategy: MissingDataStrategy; /** * Minimum answered contributing items (per scale/subscale) needed to * produce a score; below it evaluation throws `incomplete_response`. * Defaults to 1 for `prorate`, 0 otherwise. */ minAnswered?: number; } /** Sums all answers into a single total score (PHQ-9, GAD-7, ...). */ export interface SumScoring { kind: "sum"; /** Multiplier applied to the raw sum (WHO-5 reports raw x 4 as 0-100). */ multiplier?: number; /** Severity bands the (multiplied) total is matched against. */ bands?: ScoreBand[]; flags?: ScoreFlag[]; missing?: MissingDataPolicy; } /** One subscale of a multi-scale scoring definition. */ export interface SubscaleDefinition { /** Stable machine-readable id, e.g. `"depression"`. */ id: string; label: LocalizedText; /** Ids of the questions contributing to this subscale. */ questionIds: string[]; /** Multiplier applied to the raw subscale sum (DASS-21 uses 2). */ multiplier?: number; bands?: ScoreBand[]; } /** Scores groups of items into named subscales (DASS-21, ...). */ export interface SubscaleScoring { kind: "subscales"; subscales: SubscaleDefinition[]; flags?: ScoreFlag[]; /** Applied to each subscale independently. */ missing?: MissingDataPolicy; } /** One item of a `count` scoring: when it counts as screen-positive. */ export interface CountScoringItem { questionId: string; /** * The item counts as positive when its answer value is >= this. * `reverseScored` questions are inverted against their scale first. */ minValue: number; } /** * Counts how many items cross their per-item threshold (ASRS v1.1, * AQ-10, ...). The score is the number of positive items. */ export interface CountScoring { kind: "count"; items: CountScoringItem[]; /** Bands the positive-item count is matched against. */ bands?: ScoreBand[]; flags?: ScoreFlag[]; /** `prorate` scales the positive count by totalItems / answeredItems. */ missing?: MissingDataPolicy; } /** Declarative, serializable scoring rules built into psytools. */ export type ScoringDefinition = SumScoring | SubscaleScoring | CountScoring; /** * Categories used by the bundled inventories to describe what an * instrument measures. `AssessmentDefinition.categories` accepts any * string, so custom assessments are free to define their own taxonomy. */ export type InventoryCategory = | "depression" | "anxiety" | "stress" | "well-being" | "adhd" | "autism" | "substance-use" | "personality" | "attachment" | "relationships" | "emotion-regulation" | "sensory-processing" | "distress" | "self-esteem" | "burnout" /** Approach and avoidance motivation — what drives a person towards reward or away from punishment. */ | "motivation" /** Positively valued traits of character, such as gratitude, curiosity, or perseverance. */ | "character-strengths" /** Intrusion, avoidance, negative alterations in cognition and mood, and arousal following a traumatic event. */ | "post-traumatic-stress" /** Burden of bodily complaints — pain, fatigue, gastrointestinal and cardiopulmonary symptoms. */ | "somatic-symptoms" /** Attitudes and behaviours around eating, body weight and shape that characterize eating disorders. */ | "disordered-eating" /** The subjective experience of lacking companionship and social connection. */ | "loneliness"; /** * Age bands used by the bundled inventories to say who an instrument is * *about*. `AssessmentDefinition.audience` accepts any string, so custom * assessments are free to define their own bands. */ export type InventoryAudience = "adult" | "adolescent" | "child"; /** * Whose report the answers are, in the bundled inventories. * `AssessmentDefinition.respondent` accepts any string, so custom * assessments are free to define their own roles. */ export type InventoryRespondent = "self" | "parent" | "teacher" | "clinician"; /** * The complete, serializable description of an assessment. * * This is the unit you persist, exchange between backend and frontend, and * feed to the `Assessment` class. */ export interface AssessmentDefinition { /** Stable unique id, e.g. `"phq9"` or `"my-clinic/intake-2026"`. */ id: string; /** Version of this definition (semver or free-form), for provenance. */ version?: string; /** * What the assessment measures, for filtering and display. Bundled * inventories use `InventoryCategory` values; custom definitions may use * any strings. */ categories?: string[]; /** * Age band(s) the instrument is written and validated for — who it is * *about*, which is the person answering only when `respondent` is * `"self"`. An array because one wording is often validated across * bands (the RSES is standard for adolescents and adults alike). * * A retrospective instrument stays with the person answering: an adult * recalling their own childhood is `["adult"]` + `"self"`, and the * childhood framing belongs in `instructions`. Bundled inventories use * `InventoryAudience` values; custom definitions may use any strings. */ audience?: string[]; /** * Whose report the answers are: the person the assessment is about * (`"self"`) or a proxy informant (`"parent"`, `"teacher"`, ...). Scalar * rather than an array because a definition is written from exactly one * point of view — parent- and teacher-report forms of the same * instrument are separate definitions with their own item wording * ("your child" vs "I"), so they cannot share one entry here. * * Not the administration mode: a clinician reading the K10 aloud as an * interview is still `"self"`, because the answers are the client's own * report. `"clinician"` means the clinician is the informant, rating the * client from their own observation. `ResponseData.respondentId` then * identifies which individual answered. Bundled inventories use * `InventoryRespondent` values; custom definitions may use any strings. */ respondent?: string; title: LocalizedText; description?: LocalizedText; /** Instructions shown to the respondent (incl. the recall timeframe). */ instructions?: LocalizedText; /** Locale used when a requested locale is missing. */ defaultLocale: string; /** Default option scale applied to every question without own options. */ options: AssessmentOption[]; questions: AssessmentQuestion[]; /** Optional declarative scoring. Custom evaluators may be used instead. */ scoring?: ScoringDefinition; /** Free-form metadata (source, license, citation, timeframe, ...). */ meta?: Record; } /** * One language for one inventory, shipped as a separate lazily-loadable * module so that only requested locales are bundled and held in memory. * Merge into a base definition with `applyLocale`. * * Packs are partial: every field but `id`/`locale` is optional, so the same * shape also works as a small local override (e.g. reword one question) via * `loadInventory(id, { overrides })` — no need to restate the whole pack. */ export interface InventoryLocalePack { /** Id of the inventory this pack translates. */ id: string; /** BCP-47 language tag of the pack, e.g. `"tr"`. */ locale: string; title?: string; description?: string; instructions?: string; /** Labels of the assessment-level option scale, by option index. Length must match the option scale's when present. */ options?: string[]; /** Per-question texts (and option labels for question-level scales), by question id. */ questions: Record; /** Band labels by band id (sum and count scoring). */ bands?: Record; /** Flag labels by flag id. */ flags?: Record; /** Subscale labels and band labels, by subscale id. */ subscales?: Record }>; } /** Answers keyed by question id. */ export type AnswerMap = Record; /** * Lifecycle of a response, tracked as a simple state machine: * * `empty` -> `in-progress` -> `complete` -> `submitted` * * Answering and clearing answers moves between the first three states in * both directions; `submit()` moves to the terminal `submitted` state. */ export type ResponseStatus = "empty" | "in-progress" | "complete" | "submitted"; /** Serialized form of an `AssessmentResponse`. */ export interface ResponseData { assessmentId: string; /** Version of the assessment definition answered, if it declared one. */ assessmentVersion?: string; answers: AnswerMap; /** Informational lifecycle state; recomputed from answers on parse. */ status?: ResponseStatus; /** Opaque identifier of the respondent (client/case id). Optional. */ respondentId?: string; /** ISO-8601 timestamps. */ startedAt: string; submittedAt?: string; meta?: Record; } /** Codes a validation issue can carry (a subset of `PsytoolsErrorCode`). */ export type ValidationIssueCode = | "unknown_question" | "invalid_value" | "missing_answer" | "duplicate_question" | "malformed"; /** A single validation problem. */ export interface ValidationIssue { code: ValidationIssueCode; message: string; questionId?: string; } export interface ValidationResult { valid: boolean; issues: ValidationIssue[]; } /** A resolved severity band in an evaluation result. */ export interface BandResult { id: string; label: LocalizedText; } /** A flag that was raised during evaluation. */ export interface TriggeredFlag { id: string; questionId: string; value: number; label: LocalizedText; } /** A single numeric score with its possible range and matched band. */ export interface ScaleScore { score: number; /** Lowest achievable score. */ min: number; /** Highest achievable score. */ max: number; /** Matched severity band, if bands are defined and one matches. */ band?: BandResult; } /** Result of a single-scale evaluation (e.g. PHQ-9 total). */ export interface ScaleResult extends ScaleScore { kind: "scale"; flags: TriggeredFlag[]; } /** Result of a multi-scale evaluation (e.g. DASS-21 subscales). */ export interface MultiScaleResult { kind: "multiscale"; scales: Array; flags: TriggeredFlag[]; } /** Result of a categorizing evaluation (e.g. attachment style). */ export interface CategoricalResult { kind: "categorical"; category: string; label?: LocalizedText; flags?: TriggeredFlag[]; } /** Escape hatch for arbitrary evaluator output. */ export interface CustomResult { kind: "custom"; data: unknown; } export type EvaluationResult = | ScaleResult | MultiScaleResult | CategoricalResult | CustomResult; /** * A custom evaluation function. Receives the definition and the validated * answers and returns any `EvaluationResult` shape. */ export type Evaluator = ( definition: AssessmentDefinition, answers: AnswerMap, ) => EvaluationResult; /** Flat, render-ready projection of an assessment in a single locale. */ export interface LocalizedAssessmentView { id: string; locale: string; title: string; description?: string; instructions?: string; questions: Array<{ id: string; /** 1-based position, handy for display. */ index: number; text: string; optional: boolean; options: Array<{ value: number; label: string }>; }>; }