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