Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/core/BlockUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ function makeSeededRandom(seed: number): () => number {
};
}

/**
* Block-level trial controller (browser counterpart of Python's BlockUnit).
*
* Generates a shuffled, weighted condition sequence for a block of trials.
*/
export class BlockUnit {
block_id: string;
block_idx: number;
Expand Down Expand Up @@ -37,6 +42,12 @@ export class BlockUnit {
this.seed = Number(seeds[block_idx] ?? settings.overall_seed ?? 2025);
}

/**
* Generate a shuffled condition sequence for this block.
*
* Uses weighted sampling to distribute remainder trials, then Fisher-Yates shuffle.
* Pass `func` to delegate generation to a custom function instead.
*/
generate_conditions(options: {
weights?: number[] | null;
func?: (...args: any[]) => string[];
Expand Down
16 changes: 16 additions & 0 deletions src/core/StimBank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,28 @@ function formatTemplate(template: string, vars: Record<string, unknown>): string
});
}

/**
* Immutable stimulus registry (browser counterpart of Python's StimBank).
*
* Holds {@link StimSpec} definitions keyed by name. Returns {@link StimRef}
* handles from {@link get} that are resolved to concrete specs at runtime.
*/
export class StimBank {
private readonly config: Record<string, StimSpec>;

constructor(config: Record<string, StimSpec>) {
this.config = config;
}

/** Return a {@link StimRef} handle for a named stimulus. Throws if not found. */
get(key: string): StimRef {
if (!this.config[key]) {
throw new Error(`Stimulus '${key}' not found.`);
}
return { kind: "stim_ref", key };
}

/** Resolve a ref or key to a deep-cloned {@link StimSpec}. Throws if not found. */
resolve(ref: StimRef | string): StimSpec {
const key = typeof ref === "string" ? ref : ref.key;
const spec = this.config[key];
Expand Down Expand Up @@ -59,6 +67,10 @@ export class StimBank {
return this.format(name, vars);
}

/**
* Register `<key>_voice` entries for the given text stimuli.
* Uses pre-recorded audio assets when available, falls back to Web Speech API.
*/
convert_to_voice(
keys: string[] | string,
options: {
Expand Down Expand Up @@ -140,6 +152,10 @@ export class StimBank {
}
}

/**
* Resolve a potentially late-bound stimulus input to a concrete {@link StimSpec}.
* Handles {@link Resolver} functions, {@link StateRef} lookups, string keys, and {@link StimRef} handles.
*/
export function resolveStimInput(
input: Resolvable<StimRef | StimSpec | null>,
snapshot: TrialSnapshot,
Expand Down
7 changes: 7 additions & 0 deletions src/core/StimUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { TrialBuilder } from "./TrialBuilder";

type StageStatePatch = Record<string, Resolvable<unknown>>;

/**
* Builder for a single trial stage (browser counterpart of Python's StimUnit).
*
* Chain `.addStim()` → `.show()` / `.captureResponse()` / `.waitAndContinue()` →
* optionally `.set_state()` / `.to_dict()` → then call `.compile()` to produce a {@link CompiledStage}.
*/
export class StimUnit {
readonly label: string;
private readonly trial: TrialBuilder;
Expand Down Expand Up @@ -125,6 +131,7 @@ export class StimUnit {
return this;
}

/** Create a {@link StateRef} pointing to a key in this unit's runtime state. */
ref<T = unknown>(key: string): StateRef<T> {
return {
kind: "state_ref",
Expand Down
7 changes: 7 additions & 0 deletions src/core/SubInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ function coerceFieldValue(field: SubInfoField, raw: string): string | number {
return raw;
}

/**
* Participant information form (browser counterpart of Python's SubInfo).
*
* Renders an HTML form from field definitions and returns validated responses
* as a Promise resolved on submit.
*/
export class SubInfo {
private readonly fields: SubInfoField[];
private readonly mapping: Record<string, string>;
Expand All @@ -25,6 +31,7 @@ export class SubInfo {
this.mapping = config.subinfo_mapping;
}

/** Render the participant form into `container` and resolve with validated responses on submit. */
collect(container: HTMLElement): Promise<Record<string, string | number>> {
container.innerHTML = "";
const wrapper = document.createElement("div");
Expand Down
12 changes: 12 additions & 0 deletions src/core/TaskSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ function makeSeededRandom(seed: number): () => number {
};
}

/**
* Experiment configuration container (browser counterpart of Python's TaskSettings).
*
* Create via {@link TaskSettings.from_dict} with a parsed YAML config object.
* Holds window display, block/trial structure, seeding, and condition weights.
*/
export class TaskSettings {
[key: string]: unknown;

Expand All @@ -38,6 +44,10 @@ export class TaskSettings {
trial_per_block?: number;
subject_id?: string;

/**
* Create a TaskSettings from a flat config dictionary.
* Validates `trial_per_block` consistency and initialises block seeds.
*/
static from_dict(config: SettingsLike): TaskSettings {
const settings = new TaskSettings();
Object.assign(settings, config);
Expand Down Expand Up @@ -71,6 +81,7 @@ export class TaskSettings {
.map(() => Math.floor(rng() * 100000));
}

/** Merge participant info into settings and derive per-subject seeds when `seed_mode` is `"same_within_sub"`. */
add_subinfo(subinfo: Record<string, unknown>): void {
Object.assign(this, subinfo);
const subjectId = String(subinfo.subject_id ?? "");
Expand All @@ -88,6 +99,7 @@ export class TaskSettings {
}
}

/** Return validated weight vector aligned to `conditions`, or `null` for equal weighting. */
resolve_condition_weights(): number[] | null {
const raw = this.condition_weights;
if (raw == null) {
Expand Down
7 changes: 7 additions & 0 deletions src/core/TrialBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type {
} from "./types";
import { StimUnit } from "./StimUnit";

/**
* Builds a single {@link CompiledTrial} from an ordered sequence of {@link StimUnit} stages.
*
* Typical usage: `new TrialBuilder(meta).unit("fixation").addStim(...).show()...build()`
*/
export class TrialBuilder {
readonly trial_id: number | string;
readonly block_id: string | null;
Expand All @@ -27,6 +32,7 @@ export class TrialBuilder {
this.condition = meta.condition;
}

/** Create and register a new {@link StimUnit} stage with the given label. */
unit(label: string): StimUnit {
const unit = new StimUnit(label, this);
this.units.push(unit);
Expand All @@ -42,6 +48,7 @@ export class TrialBuilder {
this.trialState[key] = value;
}

/** Compile all registered units into a {@link CompiledTrial}. */
build(): CompiledTrial {
return {
trial_id: this.trial_id,
Expand Down
10 changes: 10 additions & 0 deletions src/core/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ const RAW_FIELDS = new Set([
"task_factors"
]);

/**
* Resolve a {@link Resolvable} value: call it if it's a {@link Resolver},
* look up state if it's a {@link StateRef}, or return it as-is.
*/
export function resolveValue<T>(
value: Resolvable<T>,
snapshot: TrialSnapshot,
Expand All @@ -63,6 +67,12 @@ export function splitRawExtraData(unitState: Record<string, unknown>): Record<st
return extra;
}

/**
* Records trial execution data and produces raw JSONL + reduced CSV output.
*
* Implements {@link RuntimeView} so that Resolvers and finalizers can query
* accumulated results during execution.
*/
export class ExecutionRecorder implements RuntimeView {
private readonly trialStore = new Map<
number | string,
Expand Down
33 changes: 33 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
/**
* Core type definitions for the psyflow-web runtime.
*
* Key concepts:
* - **StimRef / StimSpec**: references to stimuli vs. their concrete specs
* - **StateRef / Resolver / Resolvable**: late-bound values resolved at runtime
* - **CompiledTrial / CompiledStage**: the fully-built trial structure executed by the runtime
* - **RawStageRow / ReducedTrialRow**: output data shapes (per-stage vs. per-trial)
*
* @module
*/

export type Primitive = string | number | boolean | null;

/** Reference to a named stimulus in a {@link StimBank}. Resolved to a concrete {@link StimSpec} at runtime. */
export interface StimRef {
kind: "stim_ref";
key: string;
}

/**
* Late-bound reference to a value stored in another StimUnit's state.
* Resolved during trial execution via {@link resolveValue}.
*/
export interface StateRef<T = unknown> {
kind: "state_ref";
unit_label: string;
key: string;
__type?: T;
}

/** Read-only snapshot of a trial's accumulated state, passed to Resolvers and finalizers. */
export interface TrialSnapshot {
trial_id: number | string;
block_id: string | null;
Expand All @@ -21,12 +39,18 @@ export interface TrialSnapshot {
trial_state: Record<string, unknown>;
}

/** Read-only view of the execution recorder, available to Resolvers and finalizers. */
export interface RuntimeView {
getReducedRows(): ReducedTrialRow[];
sumReducedField(field: string): number;
}

/** A function that computes a value at runtime from the current trial state. */
export type Resolver<T> = (snapshot: TrialSnapshot, runtime: RuntimeView) => T;
/**
* A value that may be literal, a {@link StateRef} to another unit's state,
* or a {@link Resolver} function evaluated at runtime.
*/
export type Resolvable<T> = T | StateRef<T> | Resolver<T>;

export type StimSpec =
Expand Down Expand Up @@ -117,6 +141,7 @@ export interface SpeechStimSpec extends BaseStimSpec {
volume?: number;
}

/** Configuration for keyboard response capture during a trial stage. */
export interface ResponseConfig {
keys: string[];
correct_keys?: string[];
Expand All @@ -138,6 +163,10 @@ export interface TrialContextSpec {
stim_features?: Record<string, unknown> | null;
}

/**
* A single stage within a compiled trial (e.g. show stimulus, capture response, wait).
* Built by {@link StimUnit} and executed by the jsPsych plugin.
*/
export interface CompiledStage {
unit_label: string;
op: "show" | "capture_response" | "wait_and_continue";
Expand All @@ -163,6 +192,7 @@ export type TrialFinalizer = (
helpers: TrialFinalizeHelpers
) => void;

/** A fully compiled trial containing an ordered list of stages, built by {@link TrialBuilder}. */
export interface CompiledTrial {
trial_id: number | string;
block_id: string | null;
Expand All @@ -173,6 +203,7 @@ export interface CompiledTrial {
finalizers: TrialFinalizer[];
}

/** One row of raw JSONL output, recorded per-stage during execution. */
export interface RawStageRow {
trial_id: number | string;
block_id: string | null;
Expand Down Expand Up @@ -202,8 +233,10 @@ export interface RawStageRow {
extra_data: Record<string, unknown>;
}

/** One row of the reduced CSV output, recorded per-trial after finalization. */
export type ReducedTrialRow = Record<string, unknown>;

/** Structured result of parsing a psyflow YAML config file. */
export interface ParsedConfig {
raw: Record<string, unknown>;
task_config: Record<string, unknown>;
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
/**
* psyflow-web — browser runtime for auditable psychology experiments.
*
* **For task authors**: use {@link TaskSettings}, {@link BlockUnit}, {@link StimBank},
* {@link StimUnit}, {@link TrialBuilder}, and {@link SubInfo} to define trials.
*
* **For runtime consumers**: use {@link runPsyflowExperiment} or {@link mountTaskApp}.
*
* @module
*/

export { parsePsyflowConfig } from "./core/config";
export { TaskSettings } from "./core/TaskSettings";
export { BlockUnit } from "./core/BlockUnit";
Expand Down