обновлен формат генерации датасета
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""Finite symbolic assembly worlds; model output cannot supply its own effects.
|
||||
|
||||
These recipes describe logical prerequisites only, not physical feasibility.
|
||||
Every grounded operation in a scene is supplied by this trusted generator.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from copy import deepcopy
|
||||
|
||||
from .config import Config
|
||||
from .prompts import render
|
||||
from .structured import structured_answer
|
||||
from .language import answer_text, readable_scene, scene_summary, step_texts
|
||||
|
||||
# Skills before release must retain the component; 'installed' is a logical
|
||||
# stage, not an assertion that real geometry or process parameters are safe.
|
||||
RECIPES = {
|
||||
'manipulation': ('Перемести детали в сборочные позиции', ['pick', 'position', 'place', 'release', 'inspect'], None),
|
||||
'mechanical': ('Собери механический узел', ['pick', 'align', 'insert', 'release', 'inspect'], None),
|
||||
'fastening': ('Установи и затяни крепёж', ['pick', 'align', 'insert_fastener', 'tighten', 'release', 'inspect'], 'driver'),
|
||||
'press_fit': ('Собери прессовые соединения', ['pick', 'align', 'press_fit', 'release', 'inspect'], 'press'),
|
||||
'electrical': ('Выполни электромонтаж', ['pick', 'route_wire', 'connect', 'release', 'test_continuity', 'inspect'], 'tester'),
|
||||
'electronics': ('Установи электронные компоненты', ['pick', 'align', 'seat_component', 'release', 'test_connection', 'inspect'], 'tester'),
|
||||
'adhesive': ('Собери клеевые соединения', ['clean_surface', 'apply_adhesive', 'pick', 'align', 'bond', 'release', 'wait_cure', 'inspect'], 'dispenser'),
|
||||
'welding': ('Соедини детали сваркой', ['pick', 'align', 'clamp', 'release', 'weld', 'inspect'], 'welder'),
|
||||
'collaborative': ('Передай детали оператору для сборки', ['wait_operator', 'pick', 'handover', 'release', 'confirm_assembly', 'inspect'], None),
|
||||
'operator_assistance': ('Удерживай коробку возле оператора', ['detect', 'approach', 'pick', 'move_to_operator', 'hold', 'wait_assembly', 'move_to_storage', 'release', 'return_home'], None),
|
||||
'inspection': ('Проверь детали в контрольной позиции', ['pick', 'position', 'place', 'release', 'measure', 'inspect'], 'gauge'),
|
||||
}
|
||||
# Explicit perception and approach precede grasping in the human-level plan.
|
||||
for _domain, (_title, _recipe, _tool) in list(RECIPES.items()):
|
||||
if _domain != 'operator_assistance':
|
||||
_pick = _recipe.index('pick')
|
||||
RECIPES[_domain] = (_title, _recipe[:_pick] + ['detect', 'approach'] + _recipe[_pick:], _tool)
|
||||
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonical(value).encode()).hexdigest()
|
||||
|
||||
|
||||
def action_key(action):
|
||||
if not isinstance(action, dict) or set(action) != {'skill', 'object', 'target'}:
|
||||
raise ValueError('Действие должно иметь только skill, object, target')
|
||||
return canonical(action)
|
||||
|
||||
|
||||
def enabled(state, operation):
|
||||
return set(operation['requires']) <= state and not (set(operation['forbids']) & state)
|
||||
|
||||
|
||||
def apply(state, operation):
|
||||
if not enabled(state, operation):
|
||||
raise ValueError(f"Нарушены предусловия: {operation['action']}")
|
||||
return (state - set(operation['delete'])) | set(operation['add'])
|
||||
|
||||
|
||||
def solve(scene):
|
||||
state = set(scene['initial_state'])
|
||||
actions, trace = [], [sorted(state)]
|
||||
remaining = list(scene['operations'])
|
||||
while remaining:
|
||||
operation = next((op for op in remaining if enabled(state, op)), None)
|
||||
if operation is None:
|
||||
break
|
||||
state = apply(state, operation)
|
||||
actions.append(operation['action'])
|
||||
trace.append(sorted(state))
|
||||
remaining.remove(operation)
|
||||
status = 'ready' if set(scene['goal']) <= state else 'blocked'
|
||||
return {'status': status, 'actions': actions}, trace
|
||||
|
||||
|
||||
def validate_plan(episode, plan):
|
||||
if not isinstance(plan, dict) or set(plan) != {'status', 'actions'}:
|
||||
raise ValueError('Ожидаются status и actions')
|
||||
if plan['status'] not in ('ready', 'blocked') or not isinstance(plan['actions'], list):
|
||||
raise ValueError('Неверный статус/список действий')
|
||||
operations = {action_key(op['action']): op for op in episode['scene']['operations']}
|
||||
state = set(episode['scene']['initial_state'])
|
||||
trace = [sorted(state)]
|
||||
for action in plan['actions']:
|
||||
operation = operations.get(action_key(action))
|
||||
if operation is None:
|
||||
raise ValueError(f'Неизвестное действие или объект: {action}')
|
||||
state = apply(state, operation)
|
||||
trace.append(sorted(state))
|
||||
achieved = set(episode['scene']['goal']) <= state
|
||||
if plan['status'] == 'ready' and not achieved:
|
||||
raise ValueError('План не достигает цели')
|
||||
if plan['status'] == 'blocked':
|
||||
# This domain has an acyclic recipe dependency graph. The deterministic
|
||||
# solver is complete for its grounded operations (not arbitrary PDDL).
|
||||
if achieved or solve(episode['scene'])[0]['status'] != 'blocked':
|
||||
raise ValueError('Необоснованная блокировка выполнимой задачи')
|
||||
return trace
|
||||
|
||||
|
||||
def branch_plan(plan):
|
||||
nodes = []
|
||||
for index, action in enumerate(plan['actions']):
|
||||
nodes.append({'id': f'n{index}', 'action': action,
|
||||
'observation': 'action_succeeded',
|
||||
'on_success': f'n{index + 1}' if index + 1 < len(plan['actions']) else 'done',
|
||||
'on_failure': 'replan'})
|
||||
nodes.extend([
|
||||
{'id': 'done', 'terminal': plan['status']},
|
||||
{'id': 'replan', 'terminal': 'replan_required'},
|
||||
])
|
||||
return {'entry': 'n0' if plan['actions'] else 'done', 'nodes': nodes}
|
||||
|
||||
|
||||
def validate_branch(episode, branch):
|
||||
if set(branch) != {'entry', 'nodes'} or not isinstance(branch['nodes'], list):
|
||||
raise ValueError('Неверная схема графа')
|
||||
nodes = {node['id']: node for node in branch['nodes']}
|
||||
if len(nodes) != len(branch['nodes']):
|
||||
raise ValueError('Повторяющиеся узлы')
|
||||
operations = {action_key(op['action']): op for op in episode['scene']['operations']}
|
||||
pending = [(branch['entry'], set(episode['scene']['initial_state']), frozenset(), False)]
|
||||
paths = 0
|
||||
while pending:
|
||||
node_id, state, visited, failed = pending.pop()
|
||||
if node_id not in nodes or node_id in visited:
|
||||
raise ValueError('Отсутствующий узел или цикл')
|
||||
node = nodes[node_id]
|
||||
if failed and node.get('terminal') != 'replan_required':
|
||||
raise ValueError('После сбоя необходима новая сцена и перепланирование')
|
||||
if 'terminal' in node:
|
||||
if set(node) != {'id', 'terminal'}:
|
||||
raise ValueError('Неверный терминальный узел')
|
||||
status = node['terminal']
|
||||
if status == 'ready':
|
||||
if not set(episode['scene']['goal']) <= state:
|
||||
raise ValueError('Ветка не достигает цели')
|
||||
elif status == 'blocked':
|
||||
if solve(episode['scene'])[0]['status'] != 'blocked':
|
||||
raise ValueError('Необоснованная блокировка')
|
||||
elif status == 'replan_required':
|
||||
if not failed:
|
||||
raise ValueError('Перепланирование без наблюдаемого сбоя')
|
||||
else:
|
||||
raise ValueError('Неизвестный терминальный статус')
|
||||
paths += 1
|
||||
continue
|
||||
if set(node) != {'id', 'action', 'observation', 'on_success', 'on_failure'} or node['observation'] != 'action_succeeded':
|
||||
raise ValueError('Неполный узел или неизвестное наблюдение')
|
||||
operation = operations.get(action_key(node['action']))
|
||||
if operation is None:
|
||||
raise ValueError('Неизвестная операция')
|
||||
success = apply(state, operation)
|
||||
visited = visited | {node_id}
|
||||
# The defined failure outcome has no success effects. Stop and obtain a
|
||||
# fresh scene; never blindly retry a physical action after a failure.
|
||||
pending.append((node['on_failure'], state, visited, True))
|
||||
pending.append((node['on_success'], success, visited, failed))
|
||||
if len(pending) + paths > 10000:
|
||||
raise ValueError('Слишком много путей в графе')
|
||||
return paths
|
||||
|
||||
|
||||
def make_episode(index: int, config: Config):
|
||||
rng = random.Random(f'{config.seed}:{index}')
|
||||
domain = config.domains[index % len(config.domains)]
|
||||
title, recipe, tool = RECIPES[domain]
|
||||
missing = set(recipe) - set(config.prompts.action_templates)
|
||||
if missing:
|
||||
raise ValueError(f'В action_templates отсутствуют навыки: {sorted(missing)}')
|
||||
count = rng.randint(config.min_components, config.max_components)
|
||||
state = {'gripper_empty', 'fixture_ready', 'operator_ready'}
|
||||
if domain == 'operator_assistance':
|
||||
state.add('robot_home')
|
||||
if tool:
|
||||
state.add(f'tool_available:{tool}')
|
||||
components, operations = [], []
|
||||
blocked = rng.random() < config.blocked_fraction
|
||||
unavailable = rng.randrange(count) if blocked else -1
|
||||
for i in range(count):
|
||||
obj, target = f'part_{i}', f'slot_{i}'
|
||||
dependencies = [f'part_{j}' for j in range(i) if rng.random() < 0.35]
|
||||
# Positions, part classes and progress have operational meaning, and are
|
||||
# included in the signature. No free-form names enter deduplication.
|
||||
location = rng.choice(['tray', 'buffer', 'conveyor'])
|
||||
components.append({'id': obj, 'target': target, 'type': rng.choice(['small', 'medium', 'large']),
|
||||
'source_location': location, 'dependencies': dependencies,
|
||||
'name': f"{config.prompts.object_names.get(domain, 'деталь')} №{i + 1}",
|
||||
'target_name': f"{'зона хранения' if domain == 'operator_assistance' else 'сборочная позиция'} №{i + 1}"})
|
||||
state.add(f'at:{obj}:{location}')
|
||||
if i != unavailable:
|
||||
state.add(f'available:{obj}')
|
||||
prefix = f'{obj}:stage:'
|
||||
# Some parts are already installed; final inspection is still required.
|
||||
start = len(recipe) - 1 if domain != 'operator_assistance' and i != unavailable and not dependencies and rng.random() < 0.2 else 0
|
||||
state.add(f'{prefix}{start}')
|
||||
for stage, skill in enumerate(recipe):
|
||||
requires = [f'{prefix}{stage}', 'fixture_ready', f'available:{obj}']
|
||||
adds, deletes = [f'{prefix}{stage + 1}'], [f'{prefix}{stage}']
|
||||
if skill == 'pick':
|
||||
requires += ['gripper_empty', f'at:{obj}:{location}']
|
||||
requires += [f'complete:{dependency}' for dependency in dependencies]
|
||||
adds += [f'holding:{obj}']
|
||||
deletes += ['gripper_empty', f'at:{obj}:{location}']
|
||||
elif skill == 'release':
|
||||
requires += [f'holding:{obj}']
|
||||
adds += ['gripper_empty', f'at:{obj}:{target}']
|
||||
deletes += [f'holding:{obj}']
|
||||
elif 'pick' in recipe[:stage] and 'release' not in recipe[:stage]:
|
||||
requires += [f'holding:{obj}']
|
||||
if skill in ('tighten', 'press_fit', 'apply_adhesive', 'weld', 'measure', 'test_connection', 'test_continuity') and tool:
|
||||
requires += [f'tool_available:{tool}']
|
||||
if skill == 'approach':
|
||||
requires += ['gripper_empty']
|
||||
if domain == 'operator_assistance':
|
||||
deletes += ['robot_home']
|
||||
if skill == 'wait_assembly':
|
||||
adds += [f'operator_finished:{obj}']
|
||||
if skill == 'move_to_storage':
|
||||
requires += [f'operator_finished:{obj}']
|
||||
if skill == 'return_home':
|
||||
requires += ['gripper_empty']
|
||||
adds += ['robot_home']
|
||||
if skill in ('wait_operator', 'handover', 'confirm_assembly', 'move_to_operator', 'hold', 'wait_assembly'):
|
||||
requires += ['operator_ready']
|
||||
if stage == len(recipe) - 1:
|
||||
adds += [f'complete:{obj}']
|
||||
operations.append({'action': {'skill': skill, 'object': obj, 'target': target},
|
||||
'requires': requires, 'forbids': [], 'add': adds, 'delete': deletes,
|
||||
'failure_effects': []})
|
||||
if start:
|
||||
state.discard(f'at:{obj}:{location}')
|
||||
state.add(f'at:{obj}:{target}')
|
||||
scene = {'domain': domain, 'components': components,
|
||||
'initial_state': sorted(state), 'goal': ['gripper_empty'] + [f'complete:part_{i}' for i in range(count)],
|
||||
'operations': operations}
|
||||
if domain == 'operator_assistance':
|
||||
scene['goal'].append('robot_home')
|
||||
signature = digest(scene)
|
||||
plan, trace = solve(scene)
|
||||
episode = {'scenario_id': signature, 'signature': signature, 'candidate_index': index,
|
||||
'instruction': config.prompts.task_instructions[domain],
|
||||
'scene': scene, 'plan': plan, 'trace': trace, 'branch': branch_plan(plan),
|
||||
'validation_level': 'logical_only', 'source': {'provider': 'symbolic', 'model': None}}
|
||||
validate_plan(episode, plan)
|
||||
validate_branch(episode, episode['branch'])
|
||||
return episode
|
||||
|
||||
|
||||
def split_for(signature, config):
|
||||
value = int(signature[:16], 16) / 2**64
|
||||
return 'train' if value < config.split[0] else 'validation' if value < sum(config.split[:2]) else 'test'
|
||||
|
||||
|
||||
def training_rows(episode, config, *, output_format='text'):
|
||||
"""All views share the same split; only pre-action state enters step input."""
|
||||
if output_format not in ('text', 'structured'):
|
||||
raise ValueError('Неизвестный формат ответа')
|
||||
common = {'instruction': episode['instruction'],
|
||||
'scene': readable_scene(episode['scene'], episode['scene']['initial_state'], config.prompts)}
|
||||
for mode in config.modes:
|
||||
if mode == 'step':
|
||||
actions = episode['plan']['actions']
|
||||
indices = range(len(actions)) if config.step_sampling == 'all' else [int(episode['signature'][:8], 16) % max(1, len(actions))]
|
||||
if not actions:
|
||||
indices = [0]
|
||||
for step in indices:
|
||||
prompt = deepcopy(common)
|
||||
prompt['scene'] = readable_scene(episode['scene'], episode['trace'][step], config.prompts)
|
||||
prompt['history'] = step_texts(episode, config.prompts)[:step]
|
||||
answer = {'status': 'continue', 'action': actions[step]} if actions else {'status': episode['plan']['status'], 'action': None}
|
||||
yield row(episode, mode, step, prompt, answer, config, output_format)
|
||||
else:
|
||||
prompt = deepcopy(common)
|
||||
if mode == 'branch':
|
||||
prompt['observations'] = ['действие выполнено успешно', 'при выполнении произошла ошибка']
|
||||
yield row(episode, mode, 0, prompt, episode['plan'] if mode == 'full' else episode['branch'], config, output_format)
|
||||
|
||||
|
||||
def row(episode, mode, index, prompt, answer, config, output_format):
|
||||
prompt['mode'] = mode
|
||||
structured = output_format == 'structured'
|
||||
public_answer = structured_answer(episode, mode, index, config.prompts) if structured else None
|
||||
return {'record_id': f"{episode['scenario_id']}:{mode}:{index}",
|
||||
'scenario_id': episode['scenario_id'], 'split': split_for(episode['signature'], config),
|
||||
'mode': mode, 'validation_level': 'logical_only', 'source': episode['source'],
|
||||
'task': episode['instruction'], 'scene_summary': scene_summary(prompt['scene']),
|
||||
'status': public_answer['status'] if structured else (('continue' if episode['plan']['actions'] else episode['plan']['status']) if mode == 'step' else episode['plan']['status']),
|
||||
'text_source': episode.get('text_source', 'template'),
|
||||
'output_format': output_format,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': (config.prompts.structured_system if structured else config.prompts.training_system)[mode]},
|
||||
{'role': 'user', 'content': render(config.prompts.training_user, canonical(prompt))},
|
||||
{'role': 'assistant', 'content': canonical(public_answer) if structured else answer_text(episode, mode, index, config.prompts)},
|
||||
]}
|
||||
Reference in New Issue
Block a user