/ concept-collection / stan-web-ide
Sign in
concept-collection / stan-web-ide
stan-web-ide / src / stan / sampleConfig.ts
169 lines · 5.7 KBBlameHistoryRaw
1import { parse, parseDocument } from 'yaml';
3// .sample files: a YAML description of one sampling run —
4//
5// stan: linear.stan # the Stan program
6// data: data.json # the data file
7// num_chains: 4 # optional, with stan-playground's defaults
8// num_warmup: 1000
9// num_samples: 1000
10// init_radius: 2.0
11// seed: 42 # omit for a random seed
12//
13// File references are relative to the .sample file's directory; a leading
14// '/' means the project root. Results go to a directory derived from the
15// file's name (see outputDirFor), not configured in the YAML.
17export interface SampleFileConfig {
18 stan?: string;
19 data?: string;
20 num_chains: number;
21 num_warmup: number;
22 num_samples: number;
23 init_radius: number;
24 seed?: number;
27export const samplingDefaults = {
28 num_chains: 4,
29 num_warmup: 1000,
30 num_samples: 1000,
31 init_radius: 2.0,
32} as const;
34export const KNOWN_KEYS = ['stan', 'data', 'num_chains', 'num_warmup', 'num_samples', 'init_radius', 'seed'] as const;
36/** The run's output directory, derived from the .sample file's path:
37 * /a/b/fit.sample → /a/b/fit.out (replaced on each run). */
38export function outputDirFor(samplePath: string): string {
39 const dir = dirnameOf(samplePath);
40 const name = samplePath.split('/').pop() ?? '';
41 const stem = name.endsWith('.sample') ? name.slice(0, -'.sample'.length) : name;
42 return `${dir === '/' ? '' : dir}/${stem || 'run'}.out`;
45export interface ParsedSampleFile {
46 config: SampleFileConfig;
47 /** Problems that make the config unrunnable. */
48 errors: string[];
49 /** Non-fatal issues (unknown keys, ...). */
50 warnings: string[];
53export function parseSampleFile(text: string): ParsedSampleFile {
54 const errors: string[] = [];
55 const warnings: string[] = [];
56 const config: SampleFileConfig = { ...samplingDefaults };
58 let raw: unknown;
59 try {
60 raw = parse(text);
61 } catch (error) {
62 return { config, errors: [`invalid YAML: ${error instanceof Error ? error.message : error}`], warnings };
63 }
64 if (raw === null || raw === undefined) {
65 raw = {};
66 }
67 if (typeof raw !== 'object' || Array.isArray(raw)) {
68 return { config, errors: ['the .sample file must be a YAML mapping'], warnings };
69 }
70 const record = raw as Record<string, unknown>;
72 for (const key of Object.keys(record)) {
73 if (key === 'output_dir') {
74 warnings.push("'output_dir' is no longer configurable (ignored) — results go to <sample-file-name>.out");
75 } else if (!(KNOWN_KEYS as readonly string[]).includes(key)) {
76 warnings.push(`unknown key '${key}' (ignored)`);
77 }
78 }
80 const str = (key: 'stan' | 'data'): string | undefined => {
81 const value = record[key];
82 if (value === undefined || value === null) {
83 return undefined;
84 }
85 if (typeof value !== 'string' || !value.trim()) {
86 errors.push(`'${key}' must be a non-empty string`);
87 return undefined;
88 }
89 return value.trim();
90 };
91 config.stan = str('stan');
92 config.data = str('data');
94 const num = (key: 'num_chains' | 'num_warmup' | 'num_samples' | 'init_radius' | 'seed', opts: { min: number; max?: number; integer: boolean }): number | undefined => {
95 const value = record[key];
96 if (value === undefined || value === null) {
97 return undefined;
98 }
99 if (typeof value !== 'number' || !Number.isFinite(value)
100 || (opts.integer && !Number.isInteger(value))
101 || value < opts.min || (opts.max !== undefined && value > opts.max)) {
102 const range = opts.max !== undefined ? `${opts.min}..${opts.max}` : `>= ${opts.min}`;
103 errors.push(`'${key}' must be ${opts.integer ? 'an integer' : 'a number'} (${range})`);
104 return undefined;
105 }
106 return value;
107 };
108 config.num_chains = num('num_chains', { min: 1, max: 8, integer: true }) ?? samplingDefaults.num_chains;
109 config.num_warmup = num('num_warmup', { min: 0, integer: true }) ?? samplingDefaults.num_warmup;
110 config.num_samples = num('num_samples', { min: 1, integer: true }) ?? samplingDefaults.num_samples;
111 config.init_radius = num('init_radius', { min: 0, integer: false }) ?? samplingDefaults.init_radius;
112 config.seed = num('seed', { min: 0, integer: true });
114 if (!config.stan) {
115 errors.push("missing 'stan': the Stan program to compile and run");
116 } else if (!config.stan.endsWith('.stan')) {
117 errors.push("'stan' must reference a .stan file");
118 }
119 if (!config.data) {
120 errors.push("missing 'data': the JSON data file");
121 }
123 return { config, errors, warnings };
126/**
127 * Sets (or, with undefined, removes) one top-level key in the YAML text,
128 * preserving comments and formatting of everything else.
129 */
130export function updateSampleYaml(text: string, key: string, value: string | number | undefined): string {
131 const doc = parseDocument(text);
132 if (doc.contents === null || doc.contents === undefined) {
133 // empty document: build a fresh mapping
134 return value === undefined ? text : `${key}: ${JSON.stringify(value)}\n`;
135 }
136 if (value === undefined) {
137 doc.delete(key);
138 } else {
139 doc.set(key, value);
140 }
141 return doc.toString();
144/**
145 * Resolves a file reference from a .sample file: relative to the .sample
146 * file's directory, or from the project root with a leading '/'.
147 * Returns a normalized absolute project path.
148 */
149export function resolveProjectPath(sampleFileDir: string, reference: string): string {
150 const joined = reference.startsWith('/') ? reference : `${sampleFileDir}/${reference}`;
151 const parts: string[] = [];
152 for (const part of joined.split('/')) {
153 if (part === '' || part === '.') {
154 continue;
155 }
156 if (part === '..') {
157 parts.pop();
158 } else {
159 parts.push(part);
160 }
161 }
162 return '/' + parts.join('/');
165/** The directory of a project file path ('/a/b/c.sample' → '/a/b'). */
166export function dirnameOf(path: string): string {
167 const index = path.lastIndexOf('/');
168 return index <= 0 ? '/' : path.slice(0, index);
moveopenescclose