обновлен формат генерации датасета

This commit is contained in:
Даниил Грабарь
2026-10-22 10:07:05 +10:00
parent 9e551a2ca9
commit 1059b47978
29 changed files with 2230 additions and 468 deletions
+114
View File
@@ -0,0 +1,114 @@
"""Behavioral tests for exact sizes, restart safety and logical plan validation."""
import asyncio
import copy
import json
import tempfile
import unittest
from pathlib import Path
class AssemblyTests(unittest.TestCase):
def test_preinstalled_parts_do_not_depend_on_unfinished_assembly(self):
from assembly_dataset.world import make_episode, RECIPES
from assembly_dataset.config import Config
for index in range(100):
episode = make_episode(index, Config())
scene = episode['scene']
last = len(RECIPES[scene['domain']][1]) - 1
for component in scene['components']:
if f"{component['id']}:stage:{last}" in scene['initial_state']:
self.assertEqual(component['dependencies'], [])
def test_exact_counts_resume_and_split_grouping(self):
from assembly_dataset.config import Config
from assembly_dataset.pipeline import generate, export
for count in (0, 1, 2, 7, 100):
with self.subTest(count=count), tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=count, shard_size=11)
asyncio.run(generate(config))
export(config)
rows = [json.loads(line) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')
for line in p.read_text().splitlines()]
self.assertEqual(len(rows), count)
self.assertEqual(len({r['record_id'] for r in rows}), count)
groups = {}
for r in rows:
self.assertEqual(groups.setdefault(r['scenario_id'], r['split']), r['split'])
asyncio.run(generate(config))
export(config)
self.assertEqual(sum(len(p.read_text().splitlines()) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')), count)
config.target_examples = count + 4
asyncio.run(generate(config))
export(config)
self.assertEqual(sum(len(p.read_text().splitlines()) for p in Path(tmp).glob('sft/*/*/[0-9][0-9][0-9][0-9][0-9][0-9].jsonl')), count + 4)
def test_rejects_changed_generation_settings_on_resume(self):
from assembly_dataset.config import Config
from assembly_dataset.pipeline import generate
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=4)
asyncio.run(generate(config))
config.seed += 1
with self.assertRaisesRegex(ValueError, 'config|конфиг'):
asyncio.run(generate(config))
def test_plans_and_branches_are_executable(self):
from assembly_dataset.world import make_episode, validate_plan, validate_branch
from assembly_dataset.config import Config
for index in range(100):
episode = make_episode(index, Config())
validate_plan(episode, episode['plan'])
validate_branch(episode, episode['branch'])
if episode['plan']['actions']:
invalid = copy.deepcopy(episode['plan'])
invalid['actions'][0]['object'] = 'does_not_exist'
with self.assertRaises(ValueError):
validate_plan(episode, invalid)
invalid_branch = copy.deepcopy(episode['branch'])
invalid_branch['nodes'][0]['on_failure'] = 'missing_node'
with self.assertRaises(ValueError):
validate_branch(episode, invalid_branch)
def test_missing_goal_and_cycles_are_rejected(self):
from assembly_dataset.world import make_episode, validate_plan, validate_branch
from assembly_dataset.config import Config
episode = next(e for i in range(100) if (e := make_episode(i, Config()))['plan']['status'] == 'ready')
broken = copy.deepcopy(episode['plan'])
broken['actions'] = []
with self.assertRaises(ValueError):
validate_plan(episode, broken)
branch = copy.deepcopy(episode['branch'])
branch['nodes'][0]['on_success'] = branch['nodes'][0]['id']
with self.assertRaises(ValueError):
validate_branch(episode, branch)
def test_failure_branch_requires_fresh_scene_before_another_action(self):
from assembly_dataset.world import make_episode, validate_branch
from assembly_dataset.config import Config
episode = make_episode(0, Config(blocked_fraction=0))
branch = copy.deepcopy(episode['branch'])
retry = copy.deepcopy(branch['nodes'][0])
retry['id'] = 'retry'
branch['nodes'][0]['on_failure'] = 'retry'
branch['nodes'].append(retry)
with self.assertRaises(ValueError):
validate_branch(episode, branch)
def test_reproducibility_and_structural_diversity(self):
from assembly_dataset.world import make_episode
from assembly_dataset.config import Config
config = Config()
signatures = {make_episode(i, config)['signature'] for i in range(1000)}
self.assertGreater(len(signatures), 990)
self.assertEqual(make_episode(12, config), make_episode(12, config))
def test_config_rejects_invalid_counts_modes_and_splits(self):
from assembly_dataset.config import Config
for kwargs in ({'target_examples': -1}, {'target_examples': 1.5}, {'modes': []},
{'modes': ['full', 'full']}, {'split': [0.8, 0.5, 0.1]}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
Config(**kwargs)
if __name__ == '__main__':
unittest.main()
+113
View File
@@ -0,0 +1,113 @@
import asyncio
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from assembly_dataset.config import Config, TeacherConfig
from assembly_dataset.pipeline import generate, export, validate
from assembly_dataset.teacher import Plan
from assembly_dataset.world import solve
from assembly_dataset.language import action_text
from llm.config import OpenAiConfig
class TestAgent:
def __init__(self, invalid=False):
self.active = 0
self.peak = 0
self.invalid = invalid
async def run(self, prompt, **kwargs):
self.active += 1
self.peak = max(self.peak, self.active)
try:
await asyncio.sleep(0.001)
plan = {'status': 'ready', 'actions': []} if self.invalid else solve(json.loads(prompt)['scene'])[0]
if plan['actions']:
scene = json.loads(prompt)['scene']
plan['actions'] = [{**a, 'text': action_text(a, scene, Config().prompts)} for a in plan['actions']]
return SimpleNamespace(output=Plan.model_validate(plan))
finally:
self.active -= 1
class PipelineTests(unittest.TestCase):
def teacher_config(self, path):
return Config(output_dir=Path(path), target_examples=30,
teacher=TeacherConfig(enabled=True, fraction=1, concurrency=2, retries=1,
llm=OpenAiConfig(provider='openai', model='test')))
def test_teacher_concurrency_and_validated_provenance(self):
with tempfile.TemporaryDirectory() as tmp:
config = self.teacher_config(tmp)
agent = TestAgent()
asyncio.run(generate(config, agent))
report = export(config)
self.assertEqual(agent.peak, 2)
self.assertEqual(report['sources'], {'openai': 10})
self.assertEqual(report['teacher_failures'], 0)
self.assertEqual(validate(config)['examples_checked'], 30)
def test_invalid_teacher_cannot_poison_corpus(self):
with tempfile.TemporaryDirectory() as tmp:
config = self.teacher_config(tmp)
asyncio.run(generate(config, TestAgent(invalid=True)))
report = export(config)
self.assertEqual(report['teacher_failures'], 10)
self.assertEqual(report['sources'], {'symbolic': 10})
self.assertEqual(validate(config)['examples_checked'], 30)
def test_restart_after_interrupted_generation_equals_clean_run(self):
import assembly_dataset.pipeline as pipeline
original = pipeline.make_episode
def interrupted(index, config):
if index >= 40:
raise KeyboardInterrupt()
return original(index, config)
with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
config = Config(output_dir=Path(first), target_examples=150)
with patch.object(pipeline, 'make_episode', side_effect=interrupted):
with self.assertRaises(KeyboardInterrupt):
asyncio.run(generate(config))
asyncio.run(generate(config))
resumed = export(config)
clean = Config(output_dir=Path(second), target_examples=150)
asyncio.run(generate(clean))
uninterrupted = export(clean)
self.assertEqual(resumed['files'], uninterrupted['files'])
self.assertEqual(validate(config)['examples_checked'], 150)
def test_corrupted_shard_is_detected(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=4)
asyncio.run(generate(config))
export(config)
next(Path(tmp).glob('sft/*/*/*.jsonl')).write_text('broken\n')
with self.assertRaisesRegex(ValueError, 'Повреждён'):
validate(config)
def test_all_steps_and_one_mode_obey_total(self):
for modes in (['step'], ['branch'], ['full', 'step', 'branch']):
with self.subTest(modes=modes), tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=37, modes=modes, step_sampling='all')
asyncio.run(generate(config))
report = export(config)
self.assertEqual(report['examples'], 37)
self.assertEqual(validate(config)['examples_checked'], 37)
def test_smaller_export_does_not_remove_canonical_episodes(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=100)
asyncio.run(generate(config))
original = export(config)
config.target_examples = 1
self.assertEqual(export(config)['examples'], 1)
config.target_examples = 100
self.assertEqual(export(config)['files'], original['files'])
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import csv
import json
from pathlib import Path
import tempfile
import unittest
from assembly_dataset.config import Config
from assembly_dataset.pipeline import generate, export, validate
class CsvExportTests(unittest.TestCase):
def test_each_shard_has_readable_csv_with_same_records(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=25, shard_size=2)
config.prompts.teacher_system = 'Правила'
config.prompts.training_system['full'] = 'Текст; с "кавычками"\nи новой строкой'
asyncio.run(generate(config))
export(config)
for path in Path(tmp).glob('sft/*/*/*.jsonl'):
counterpart = path.with_suffix('.csv')
self.assertTrue(counterpart.exists())
self.assertTrue(counterpart.read_bytes().startswith(b'\xef\xbb\xbf'))
originals = [json.loads(line) for line in path.read_text().splitlines()]
with counterpart.open(encoding='utf-8-sig', newline='') as file:
rows = list(csv.DictReader(file, delimiter=';'))
self.assertEqual(len(rows), len(originals))
for original, row in zip(originals, rows):
self.assertEqual(row['ID записи'], original['record_id'])
self.assertEqual(row['Системная инструкция'], original['messages'][0]['content'])
self.assertEqual(row['План'], original['messages'][2]['content'])
self.assertNotRegex(row['План'], r'part_\d|slot_\d')
if original['output_format'] == 'text':
self.assertNotIn('"skill"', row['План'])
self.assertTrue(row['План'])
self.assertEqual(validate(config)['examples_checked'], 25)
def test_corrupt_csv_is_detected(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=1)
asyncio.run(generate(config))
export(config)
files = list(Path(tmp).glob('sft/*/*/*.csv'))
self.assertEqual(len(files), 2)
files[0].write_text('broken')
with self.assertRaisesRegex(ValueError, 'Повреждён'):
validate(config)
+60
View File
@@ -0,0 +1,60 @@
import asyncio
import json
from pathlib import Path
import tempfile
import unittest
from assembly_dataset.config import Config
from assembly_dataset.pipeline import generate, export, validate
from assembly_dataset.world import make_episode, training_rows
class PairedExportTests(unittest.TestCase):
def test_paired_shards_and_exact_count(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=10, shard_size=2)
asyncio.run(generate(config))
report = export(config)
self.assertEqual(report['examples'], 10)
pairs = 0
for path in Path(tmp).glob('sft/*/*/[0-9]*.jsonl'):
if '.structured.' in path.name:
continue
structured_path = path.with_suffix('.structured.jsonl')
self.assertTrue(structured_path.exists())
text_rows = list(map(json.loads, path.read_text().splitlines()))
json_rows = list(map(json.loads, structured_path.read_text().splitlines()))
self.assertEqual(len(text_rows), len(json_rows))
for text, structured in zip(text_rows, json_rows):
self.assertEqual(text['record_id'], structured['record_id'])
self.assertEqual(text['messages'][1], structured['messages'][1])
self.assertNotEqual(text['messages'][0], structured['messages'][0])
answer = json.loads(structured['messages'][2]['content'])
self.assertEqual(set(answer), {'status', 'plan'})
self.assertNotRegex(str(answer), r'part_\d|slot_\d')
pairs += 1
self.assertEqual(pairs, 10)
self.assertEqual(validate(config)['examples_checked'], 10)
def test_structured_skills_and_branches(self):
config = Config(domains=['operator_assistance'], min_components=1, max_components=1,
blocked_fraction=0, step_sampling='all')
episode = make_episode(0, config)
rows = list(training_rows(episode, config, output_format='structured'))
full = json.loads(rows[0]['messages'][2]['content'])
actions = full['plan']
self.assertEqual(actions[2], {'skill': 'pick', 'object': 'распределительная коробка №1'})
self.assertEqual(actions[3]['target'], 'оператор')
self.assertEqual(actions[6]['target'], 'зона хранения №1')
self.assertNotIn('target', actions[7])
for row in rows[1:-1]:
self.assertEqual(len(json.loads(row['messages'][2]['content'])['plan']), 1)
branch = json.loads(rows[-1]['messages'][2]['content'])
for i, action in enumerate(branch['plan']):
self.assertEqual(action['on_failure'], 'replan_required')
self.assertEqual(action['on_success'], f'step_{i+2}' if i+1 < len(actions) else 'ready')
def test_blocked_empty_plan(self):
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=1)
for row in training_rows(make_episode(0, config), config, output_format='structured'):
self.assertEqual(json.loads(row['messages'][2]['content']), {'status': 'blocked', 'plan': []})
+45
View File
@@ -0,0 +1,45 @@
import asyncio
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from pathlib import Path
import tempfile
import unittest
from assembly_dataset.config import Config
from assembly_dataset.pipeline import generate, export
class ProgressTests(unittest.TestCase):
def test_generation_export_and_resume_report_exact_total(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=7)
output, errors = StringIO(), StringIO()
with redirect_stdout(output), redirect_stderr(errors):
asyncio.run(generate(config, show_progress=True))
report = export(config, show_progress=True)
asyncio.run(generate(config, show_progress=True))
self.assertEqual(report['examples'], 7)
self.assertEqual(output.getvalue(), '')
text = errors.getvalue()
self.assertIn('Генерация', text)
self.assertIn('Экспорт', text)
self.assertIn('7/7', text.replace(' ', ''))
self.assertIn('100%', text)
def test_progress_disabled_is_silent(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=1)
errors = StringIO()
with redirect_stderr(errors):
asyncio.run(generate(config, show_progress=False))
export(config, show_progress=False)
self.assertEqual(errors.getvalue(), '')
def test_empty_dataset_progress(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=0)
errors = StringIO()
with redirect_stderr(errors):
asyncio.run(generate(config, show_progress=True))
export(config, show_progress=True)
self.assertIn('0/0', errors.getvalue().replace(' ', ''))
+78
View File
@@ -0,0 +1,78 @@
import asyncio
import json
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
import yaml
from assembly_dataset.config import Config, load_config
from assembly_dataset.pipeline import generate
from assembly_dataset.world import make_episode, training_rows
from assembly_dataset.language import action_text
class PromptTests(unittest.TestCase):
def test_custom_request_and_retry_reach_teacher(self):
from assembly_dataset.teacher import enrich, Plan
from llm.config import OpenAiConfig
config = Config(blocked_fraction=0)
config.teacher.fraction = 1.0
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
config.prompts.teacher_user = 'REQUEST ${payload}'
config.prompts.teacher_retry = 'REPAIR ${payload}'
episode = make_episode(0, config)
agent = SimpleNamespace(run=AsyncMock(side_effect=[
SimpleNamespace(output=Plan(status='ready', actions=[])),
SimpleNamespace(output=Plan.model_validate({**episode['plan'], 'actions': [{**a, 'text': action_text(a, episode['scene'], config.prompts)} for a in episode['plan']['actions']]})),
]))
result, error = asyncio.run(enrich(episode, config.teacher, agent, config.prompts))
self.assertIsNone(error)
first, second = [call.args[0] for call in agent.run.call_args_list]
self.assertTrue(first.startswith('REQUEST {'))
self.assertTrue(second.startswith('REPAIR {'))
self.assertIn('validation_error', json.loads(second.removeprefix('REPAIR ')))
self.assertEqual(result['source']['provider'], 'openai')
def test_external_prompts_relative_paths_and_export(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
prompts = yaml.safe_load(Path('prompts.yaml').read_text())
prompts['training_system']['full'] = 'Custom training rules'
prompts['training_user'] = 'Input: ${payload}'
prompts['task_instructions']['mechanical'] = 'Custom assembly task'
(root / 'texts.yaml').write_text(yaml.safe_dump(prompts))
(root / 'config.yaml').write_text('prompts_file: texts.yaml\noutput_dir: data\ndomains: [mechanical]\n')
config = load_config(root / 'config.yaml')
episode = make_episode(0, config)
record = next(training_rows(episode, config))
self.assertEqual(episode['instruction'], 'Custom assembly task')
self.assertEqual(record['messages'][0]['content'], 'Custom training rules')
self.assertTrue(record['messages'][1]['content'].startswith('Input: {'))
self.assertEqual(config.output_dir, root / 'data')
def test_prompt_changes_prevent_mixed_resume(self):
with tempfile.TemporaryDirectory() as tmp:
config = Config(output_dir=Path(tmp), target_examples=1)
asyncio.run(generate(config))
config.prompts.teacher_system = 'Different rules'
with self.assertRaises(ValueError):
asyncio.run(generate(config))
def test_invalid_templates_are_rejected(self):
from assembly_dataset.prompts import Prompts
data = Config().prompts.model_dump()
for template in ('No scene', '${unknown}', '${payload.bad}'):
with self.subTest(template=template), self.assertRaises(ValueError):
Prompts.model_validate({**data, 'teacher_user': template})
def test_teacher_system_is_passed_to_agent(self):
from assembly_dataset.teacher import create_agent
from llm.config import OpenAiConfig
config = Config()
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
config.prompts.teacher_system = 'Custom teacher instructions'
with patch('llm.factory.LLMModelProvider.create_from_config', new_callable=AsyncMock), patch('pydantic_ai.Agent') as agent:
asyncio.run(create_agent(config.teacher, config.prompts))
self.assertEqual(agent.call_args.kwargs['system_prompt'], 'Custom teacher instructions')
+77
View File
@@ -0,0 +1,77 @@
import asyncio
import re
from types import SimpleNamespace
import unittest
from assembly_dataset.config import Config
from assembly_dataset.world import make_episode, training_rows, validate_plan
class RussianPlanTests(unittest.TestCase):
def test_training_answers_are_russian_text_for_all_modes(self):
config = Config(blocked_fraction=0)
episode = make_episode(0, config)
for row in training_rows(episode, config):
text = row['messages'][-1]['content']
self.assertRegex(text, '[А-Яа-я]')
self.assertNotRegex(text, r'part_\d|slot_\d|"skill"|on_success')
self.assertFalse(text.startswith('{'))
if row['mode'] == 'full':
self.assertTrue(text.startswith('1. '))
self.assertIn('Захватить', text)
def test_operator_assistance_holds_until_completion_then_returns_to_storage(self):
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=0)
episode = make_episode(0, config)
skills = [a['skill'] for a in episode['plan']['actions']]
self.assertEqual(skills, ['detect', 'approach', 'pick', 'move_to_operator', 'hold',
'wait_assembly', 'move_to_storage', 'release', 'return_home'])
validate_plan(episode, episode['plan'])
text = next(training_rows(episode, config))['messages'][-1]['content']
self.assertIn('распределительная коробка', text)
self.assertIn('завершения сборки', text)
self.assertIn('хранения', text)
self.assertIn('исходное положение', text)
def test_teacher_wording_is_exported_without_replacing_it(self):
from assembly_dataset.teacher import Plan, enrich
from assembly_dataset.language import action_text
from llm.config import OpenAiConfig
config = Config(blocked_fraction=0)
config.teacher.fraction = 1
config.teacher.llm = OpenAiConfig(provider='openai', model='test')
episode = make_episode(0, config)
actions = [{**a, 'text': action_text(a, episode['scene'], config.prompts)} for a in episode['plan']['actions']]
actions[0]['text'] = 'Сначала ' + actions[0]['text'].lower()
output = Plan(status='ready', actions=actions)
class Agent:
async def run(self, *args, **kwargs):
return SimpleNamespace(output=output)
enriched, error = asyncio.run(enrich(episode, config.teacher, Agent(), config.prompts))
self.assertIsNone(error)
text = next(training_rows(enriched, config))['messages'][-1]['content']
self.assertIn(actions[0]['text'], text)
def test_teacher_cannot_supply_only_english_skill_text(self):
from assembly_dataset.teacher import Plan
with self.assertRaises(ValueError):
Plan(status='ready', actions=[{'skill':'pick','object':'part_0','target':'slot_0','text':'pick: part_0 -> slot_0'}])
def test_russian_text_cannot_change_the_validated_action(self):
from assembly_dataset.language import action_text, validate_texts
config = Config(domains=['operator_assistance'], min_components=1, max_components=1, blocked_fraction=0)
episode = make_episode(0, config)
texts = [action_text(a, episode['scene'], config.prompts) for a in episode['plan']['actions']]
for bad in ('Бросить объект «распределительная коробка №1» на пол.',
texts[0] + ' Затем отпустить объект.'):
with self.subTest(text=bad), self.assertRaises(ValueError):
validate_texts(episode, [bad, *texts[1:]], config.prompts)
def test_approaching_another_object_cannot_interrupt_holding(self):
config = Config(domains=['operator_assistance'], min_components=2, max_components=2, blocked_fraction=0)
episode = make_episode(0, config)
actions = episode['plan']['actions']
# Execute the second object's approach while the first is being held.
reordered = actions[:5] + actions[9:11] + actions[5:9] + actions[11:]
with self.assertRaises(ValueError):
validate_plan(episode, {'status': 'ready', 'actions': reordered})