@mlx-node/trl 0.0.12 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlx-node/trl",
3
- "version": "0.0.12",
3
+ "version": "0.0.15",
4
4
  "homepage": "https://github.com/mlx-node/mlx-node",
5
5
  "bugs": {
6
6
  "url": "https://github.com/mlx-node/mlx-node/issues"
@@ -12,13 +12,15 @@
12
12
  "directory": "packages/trl"
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "src"
16
17
  ],
17
18
  "type": "module",
18
19
  "main": "./dist/index.js",
19
20
  "types": "./dist/index.d.ts",
20
21
  "exports": {
21
22
  ".": {
23
+ "@mlx-node/source": "./src/index.ts",
22
24
  "types": "./dist/index.d.ts",
23
25
  "import": "./dist/index.js"
24
26
  }
@@ -29,8 +31,8 @@
29
31
  "test:trainer": "TEST_TRAINER=1 vite test run"
30
32
  },
31
33
  "dependencies": {
32
- "@mlx-node/core": "0.0.12",
33
- "@mlx-node/lm": "0.0.12",
34
+ "@mlx-node/core": "0.0.15",
35
+ "@mlx-node/lm": "0.0.15",
34
36
  "@std/toml": "npm:@jsr/std__toml@^1.0.11",
35
37
  "change-case": "^5.4.4"
36
38
  },
@@ -0,0 +1,193 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve as resolvePath } from 'node:path';
3
+
4
+ import type {
5
+ DatasetExample,
6
+ ChatMessage,
7
+ ChatRole,
8
+ DatasetSplit,
9
+ PromptFormatterOptions,
10
+ PromptTemplate,
11
+ DatasetLoader,
12
+ } from '../types.js';
13
+ import { validatePathContainment, getAllowedRoot, type PathValidationOptions } from '../utils/path-security.js';
14
+ import { extractHashAnswer } from '../utils/xml-parser.js';
15
+
16
+ export interface LocalDatasetOptions extends PromptFormatterOptions, PathValidationOptions {
17
+ basePath?: string;
18
+ promptTemplate?: PromptTemplate;
19
+ metadata?: Record<string, unknown>;
20
+ }
21
+
22
+ interface Gsm8kRecord {
23
+ question: string;
24
+ answer: string;
25
+ }
26
+
27
+ const DEFAULT_BASE_PATH = resolvePath(process.cwd(), 'data/openai-gsm8k');
28
+ const VALID_SPLITS = new Set(['train', 'test']);
29
+
30
+ export const SYSTEM_PROMPT = `
31
+ Respond in the following format:
32
+
33
+ <reasoning>
34
+ ...
35
+ </reasoning>
36
+ <answer>
37
+ ...
38
+ </answer>
39
+ `.trim();
40
+
41
+ export const XML_COT_FORMAT = `<reasoning>
42
+ {reasoning}
43
+ </reasoning>
44
+ <answer>
45
+ {answer}
46
+ </answer>`;
47
+
48
+ const SYSTEM_MESSAGE: ChatMessage = {
49
+ role: 'system',
50
+ content: SYSTEM_PROMPT,
51
+ };
52
+
53
+ function createMessage(role: ChatRole, content: string): ChatMessage {
54
+ return { role, content };
55
+ }
56
+
57
+ export const defaultPromptTemplate: PromptTemplate = (question, options) => {
58
+ const messages: ChatMessage[] = [SYSTEM_MESSAGE];
59
+ if (options?.includeOneShot && options.oneShotExample) {
60
+ const { question: exampleQuestion, reasoning, answer } = options.oneShotExample;
61
+
62
+ messages.push(
63
+ createMessage('user', exampleQuestion),
64
+ createMessage('assistant', XML_COT_FORMAT.replace('{reasoning}', reasoning).replace('{answer}', answer)),
65
+ );
66
+ }
67
+ messages.push(createMessage('user', question));
68
+ return messages;
69
+ };
70
+
71
+ export function createDatasetExample(prompt: ChatMessage[], metadata?: Record<string, unknown>): DatasetExample {
72
+ return {
73
+ prompt: prompt.map((message) => ({ ...message })), // defensive copy
74
+ metadata: metadata ? { ...metadata } : undefined,
75
+ };
76
+ }
77
+
78
+ export function extractGsm8kAnswer(raw: string): string | null {
79
+ return extractHashAnswer(raw);
80
+ }
81
+
82
+ export function validateDatasetExample(example: DatasetExample): void {
83
+ if (!Array.isArray(example.prompt) || example.prompt.length === 0) {
84
+ throw new Error('Dataset example must contain at least one prompt message.');
85
+ }
86
+ for (const message of example.prompt) {
87
+ if (!message || typeof message.content !== 'string' || message.content.trim() === '') {
88
+ throw new Error('Prompt messages must include non-empty textual content.');
89
+ }
90
+ if (message.role !== 'system' && message.role !== 'user' && message.role !== 'assistant') {
91
+ throw new Error(`Unsupported chat role: ${String(message.role)}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ function resolveBasePath(optionPath: string | undefined, options: PathValidationOptions): string {
97
+ const allowedRoot = getAllowedRoot(options);
98
+
99
+ if (!optionPath) {
100
+ // Default path - validate it's within allowed root
101
+ validatePathContainment(DEFAULT_BASE_PATH, allowedRoot);
102
+ return DEFAULT_BASE_PATH;
103
+ }
104
+
105
+ // Resolve and validate user-provided path
106
+ const resolved = resolvePath(allowedRoot, optionPath);
107
+ validatePathContainment(resolved, allowedRoot);
108
+ return resolved;
109
+ }
110
+
111
+ function datasetFileForSplit(split: DatasetSplit): string {
112
+ if (!VALID_SPLITS.has(split)) {
113
+ throw new Error(`Unsupported GSM8K split "${split}". Expected one of: ${Array.from(VALID_SPLITS).join(', ')}`);
114
+ }
115
+ return `${split}.jsonl`;
116
+ }
117
+
118
+ function readDatasetFile(filePath: string): string {
119
+ try {
120
+ return readFileSync(filePath, 'utf8');
121
+ } catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ throw new Error(`Failed to read dataset file at ${filePath}: ${message}`);
124
+ }
125
+ }
126
+
127
+ function readJsonl(path: string, limit?: number): Gsm8kRecord[] {
128
+ const fileContents = readDatasetFile(path);
129
+ const lines = fileContents.split(/\r?\n/).filter((line) => line.trim().length > 0);
130
+ const records: Gsm8kRecord[] = [];
131
+ const max = typeof limit === 'number' && limit >= 0 ? limit : Number.POSITIVE_INFINITY;
132
+
133
+ for (let i = 0; i < lines.length && records.length < max; i += 1) {
134
+ const line = lines[i];
135
+ try {
136
+ const parsed = JSON.parse(line) as Partial<Gsm8kRecord>;
137
+ if (typeof parsed.question !== 'string' || typeof parsed.answer !== 'string') {
138
+ throw new Error('Record must include string "question" and "answer" fields.');
139
+ }
140
+ records.push({ question: parsed.question, answer: parsed.answer });
141
+ } catch (error) {
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ throw new Error(`Failed to parse JSONL record at ${path}:${i + 1} - ${message}`);
144
+ }
145
+ }
146
+
147
+ return records;
148
+ }
149
+
150
+ export async function loadLocalGsm8kDataset(
151
+ split: DatasetSplit,
152
+ options: LocalDatasetOptions & { limit?: number } = {},
153
+ ): Promise<DatasetExample[]> {
154
+ const basePath = resolveBasePath(options.basePath, options);
155
+ const fileName = datasetFileForSplit(split);
156
+ const filePath = resolvePath(basePath, fileName);
157
+
158
+ // Additional validation: ensure the final file path stays within the base path
159
+ // This protects against any edge cases where the filename could escape
160
+ validatePathContainment(filePath, basePath);
161
+
162
+ const promptTemplate = options.promptTemplate ?? defaultPromptTemplate;
163
+ const records = readJsonl(filePath, options.limit);
164
+
165
+ const examples: DatasetExample[] = records.map((record, index) => {
166
+ const prompt = promptTemplate(record.question, {
167
+ includeOneShot: options.includeOneShot,
168
+ oneShotExample: options.oneShotExample,
169
+ });
170
+ const example = createDatasetExample(prompt, {
171
+ split,
172
+ index,
173
+ raw_answer: record.answer,
174
+ ...options.metadata,
175
+ });
176
+ validateDatasetExample(example);
177
+ return example;
178
+ });
179
+
180
+ return examples;
181
+ }
182
+
183
+ export class LocalGsm8kDatasetLoader implements DatasetLoader {
184
+ private readonly options: LocalDatasetOptions;
185
+
186
+ constructor(options: LocalDatasetOptions = {}) {
187
+ this.options = { ...options };
188
+ }
189
+
190
+ async load(split: DatasetSplit, limit?: number): Promise<DatasetExample[]> {
191
+ return loadLocalGsm8kDataset(split, { ...this.options, limit });
192
+ }
193
+ }