기록의 시간·프레임·버전
80분 안팎
학습 목표
실험 manifest와 JSONL 로그에서 필수 메타데이터 누락을 판정합니다.
개념
실패를 다시 실행할 수 있는 기록
로봇이 어제는 도착하고 오늘은 멈췄다는 보고만으로는 원인을 찾기 어렵습니다. 지도가 달랐는지, 바퀴 잡음이 달랐는지, 센서가 늦었는지 구분할 재료가 없기 때문입니다. 이 레슨에서는 실행 결과를 설명하는 글보다 먼저, 다른 사람이 같은 입력을 넣을 수 있는 기록 계약을 만듭니다. 이전 모듈의 센서 처리 함수를 바꾸기 전에 어떤 설정과 데이터를 받았는지 보존합니다.
이번 모듈은 Python 3 표준 라이브러리로 실행하며 로컬 미션의 C HAL 빌드에는 gcc와 bash가 필요합니다. browser 실습은 표준 입력으로 JSON 객체 하나를 받습니다. local 실습은 zip을 별도 폴더에 풀고 bash check.sh를 실행합니다. ROS 설치나 외부 패키지가 필요하지 않습니다. 모든 위치는 m, 방향은 rad이며 가상 시각은 정수 us로 저장하고 기존 제어기에 전달할 때만 초로 변환합니다.
manifest와 JSONL의 책임을 나눕니다
manifest는 한 실행 전체의 설명서입니다. schema_version은 해석할 형식, code_version은 사람이 붙이는 코드 식별자, seed는 난수 실험 식별자, dt_s는 제어 주기입니다. frame과 units는 좌표와 숫자의 의미를 지정합니다. seed만 기록하면 지도나 제어 이득이 바뀐 실행을 같은 실험으로 오해할 수 있습니다. 미션에서는 실제 소스 파일 해시와 Python 버전도 함께 남깁니다.
JSONL은 한 줄에 이벤트 객체 하나를 담습니다. 전체 파일을 하나의 JSON 배열로 저장하는 방식과 다르며 각 줄을 json.loads로 읽습니다. goal은 목표 요청, sensor는 수신 입력, tick은 상태 갱신입니다. 센서 payload가 null이면 명시적인 누락 이벤트입니다. 아무 줄도 오지 않는 통신 중단과 null 줄이 온 경우는 다른 관측이므로 둘을 혼동하지 않습니다.
각 이벤트에는 at_us와 seq와 kind를 둡니다. at_us는 재생기가 입력을 관측한 가상 수신 시각이고 seq는 같은 시각의 순서를 보존하는 고유 정수입니다. 센서가 실제 측정한 시각은 payload.stamp_us에 따로 둡니다. 수신 시각으로 정렬한 다음 측정 시각으로 좌표를 계산해야 지연 자체가 사라지지 않습니다. 로그 파일의 줄 순서가 섞여도 이 두 키로 실행 순서를 복원합니다.
원본 파일의 해시를 연결합니다
SHA-256은 파일 바이트에서 계산하는 식별값입니다. config·map·input 파일을 확정한 뒤 읽어서 manifest.hashes에 저장합니다. 파일 이름이 같아도 내용이 바뀌면 해시가 달라질 수 있습니다. 원본 로그를 보기 좋게 다시 저장하거나 줄 끝을 변경한 뒤 이전 해시를 그대로 쓰지 않습니다. JSON을 파싱한 의미가 같다는 비교와 원본 파일 바이트가 같다는 비교는 목적이 다릅니다.
정렬된 키와 고정 구분자로 JSON을 출력하면 동일한 객체를 안정된 형태로 남길 수 있습니다. 하지만 입력 로그 검증에서는 해시를 계산하기 전에 정규화하지 않습니다. 원본이 손상되었는지 판정하려면 받은 바이트 그대로 읽어야 합니다. 결과를 저장하는 규칙은 canonical 함수로 통일하고, 입력을 지키는 규칙은 Path.read_bytes와 sha 함수로 분리합니다.
해시는 서명이나 접근 권한을 대신하지 않습니다. 공격자가 파일과 manifest를 함께 바꾸면 단순 해시 비교는 새 파일과 새 해시가 일치한다고 말할 수 있습니다. 이 실습에서는 우발적인 파일 변경을 탐지하고 실험 묶음을 식별하는 데 사용합니다. 누가 승인한 기준 기록인지를 입증하려면 별도의 보관·검토 정책이 필요하며 여기서 보안 인증을 구현했다고 주장하지 않습니다.
검증을 실행보다 먼저 합니다
브라우저 과제는 파일 업로드를 받지 않으므로 해시 문자열의 형식과 필수 필드를 검사합니다. 소문자 16진수 64자리인지 확인해도 내용 일치가 증명되지는 않습니다. 로컬 미션의 validate_manifest는 실제 세 파일과 소스 파일을 읽어서 해시를 비교합니다. 형식 검사와 내용 검증을 같은 것으로 표현하지 않는 것이 이 레슨의 중요한 제출 기준입니다.
필수 필드가 비었을 때는 정상적인 기본값으로 조용히 채우지 않습니다. 없는 seed를 0으로 처리하면 의도한 seed=0 실험과 실험 정보가 빠진 기록을 구분할 수 없습니다. 잘못된 dt_s=0은 제어 시간의 진행을 망치므로 재생을 시작하기 전에 거절합니다. Python에서 True는 정수처럼 동작할 수 있어 seed와 seq에는 type(value) is int 검사를 사용합니다.
같은 seq가 두 번 나오는 로그는 정렬만으로 고치지 않습니다. 동일 입력을 중복 저장했는지, 서로 다른 이벤트에 같은 번호를 붙였는지 알 수 없기 때문입니다. 음수 at_us는 이 실습의 시작 시각 0 계약을 위반합니다. 순서가 다른 정상 입력은 다음 레슨에서 정렬하지만, 잘못된 식별자나 시간의 형식은 여기서 거절합니다. 복원 가능한 순서와 복원 불가능한 정보 손실을 구별합니다.
오류 위치가 곧 조사 순서입니다
manifest.hashes.input이 출력되면 먼저 input 해시 문자열이 빠졌거나 형식이 틀렸는지 봅니다. 로컬의 HASH input.jsonl은 문자열 모양 문제가 아니라 원본 바이트 불일치입니다. 파일을 덮어쓴 뒤 무작정 해시만 갱신하면 실패 증거가 사라지므로 원본 묶음은 유지하고 새 실험 폴더를 만듭니다. 변경 의도를 code_version과 검토 기록에 연결합니다.
events[1].seq는 두 번째 이벤트의 순번 계약이 틀렸다는 뜻입니다. JSONDecodeError는 이 계약 검사보다 앞선 파싱 실패이며 쉼표·따옴표·잘린 줄을 확인합니다. KeyError를 포괄적으로 숨겨 OK를 반환하지 않습니다. 사용자에게 읽을 수 있는 오류 경로를 내보내되, 원본 문제를 찾을 정보와 실행을 멈춘 사실을 함께 남깁니다.
따라하기에서는 파일 내용 한 줄을 바꾸어 해시가 달라지는지, 같은 JSON의 출력 규칙이 결과 바이트에 영향을 주는지, 누락 필드가 경로로 표시되는지 순서대로 확인합니다. 작은 기록부터 계약을 통과시킨 뒤 전체 주행 로그를 연결하면 재생기의 문제와 입력 묶음의 문제를 분리할 수 있습니다. 정상 입력뿐 아니라 빈 이벤트 목록과 중복 순번도 테스트합니다.
완성한 결과에는 필수 메타데이터 목록과 검사 범위를 적습니다. 브라우저 실습의 빈 이벤트 목록은 형식상 허용되지만 목표를 실행한 실험의 증거는 아닙니다. 미션은 NO_GOAL로 실행을 거절합니다. 파일 입출력의 상세 사용법은 더 읽기에 맡기고, 이 레슨에서는 로봇 실험을 재현하는 데 필요한 필드와 손상 판정을 직접 구현합니다.
따라하기
필수 메타데이터 누락을 찾습니다
정상 manifest를 만든 뒤 seed를 제거해 검사 경로를 확인합니다. 코드를 파일로 저장하고 python3 파일명.py로 실행합니다.
import json,sys,re,math
def inspect(obj):
m=obj.get('manifest',{});events=obj.get('events');errors=[]
def need(ok,path):
if not ok: errors.append(path)
need(type(m.get('schema_version')) is int and m['schema_version']==1,'manifest.schema_version')
need(isinstance(m.get('code_version'),str) and bool(m['code_version'].strip()),'manifest.code_version')
need(type(m.get('seed')) is int,'manifest.seed')
dt=m.get('dt_s');need(type(dt) in (int,float) and math.isfinite(dt) and dt>0,'manifest.dt_s')
need(m.get('frame')=='map','manifest.frame')
need(m.get('units')=={'position':'m','yaw':'rad','time':'us'},'manifest.units')
for key in ('config','map','input'):
value=m.get('hashes',{}).get(key)
need(isinstance(value,str) and bool(re.fullmatch('[0-9a-f]{64}',value)),'manifest.hashes.'+key)
if not isinstance(events,list): return sorted(errors+['events'])
seen=set()
for i,e in enumerate(events):
if not isinstance(e,dict): errors.append(f'events[{i}]');continue
path=f'events[{i}].'
need(type(e.get('at_us')) is int and e['at_us']>=0,path+'at_us')
seq=e.get('seq');valid=type(seq) is int and seq>=0 and seq not in seen
need(valid,path+'seq')
if valid: seen.add(seq)
need(e.get('kind') in ('goal','sensor','tick'),path+'kind')
return sorted(errors)
obj={'manifest': {'schema_version': 1, 'code_version': 'robotics-m09-v1', 'seed': 0, 'dt_s': 0.05, 'frame': 'map', 'units': {'position': 'm', 'yaw': 'rad', 'time': 'us'}, 'hashes': {'config': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'map': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'input': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}}, 'events': [{'at_us': 0, 'seq': 0, 'kind': 'goal'}, {'at_us': 0, 'seq': 1, 'kind': 'sensor'}]}
print('OK' if not inspect(obj) else inspect(obj))
obj['manifest'].pop('seed')
print(','.join(inspect(obj)))
실행 결과
OK manifest.seed
원본 바이트 변경을 탐지합니다
메모리 바이트로 해시 차이를 관찰합니다. 로컬 미션은 이 계산에 실제 파일의 read_bytes를 사용합니다.
import hashlib
a=b'{"kind":"tick"}\n'
b=a+b'\n'
print('same=',hashlib.sha256(a).hexdigest()==hashlib.sha256(a).hexdigest())
print('changed=',hashlib.sha256(a).hexdigest()!=hashlib.sha256(b).hexdigest())
실행 결과
same= True changed= True
직렬화 규칙과 숫자 계약을 확인합니다
같은 키를 가진 객체의 키 순서를 고정하고 True를 정수 seed로 받아들이지 않는 이유를 살펴봅니다.
import json
a={'seed':0,'frame':'map'}
b={'frame':'map','seed':0}
encode=lambda v:json.dumps(v,sort_keys=True,separators=(',',':'))
print(encode(a))
print('same=',encode(a)==encode(b))
print('bool_is_seed=',type(True) is int)
실행 결과
{"frame":"map","seed":0}
same= True
bool_is_seed= False
확인 문제
실습
입력 manifest와 events에서 필수 필드 검사를 완성합니다. inspect 안의 TODO를 제공된 계약대로 구현합니다. schema_version=1, 비어 있지 않은 code_version, 정수 seed(음수 허용·bool 거절), 양수 유한 dt_s, frame=map, units={position:m,yaw:rad,time:us}, hashes의 config·map·input은 소문자 16진수 64자리입니다. 이벤트는 비음수 정수 at_us·전역 고유 비음수 정수 seq·kind(goal/sensor/tick)를 갖습니다. events 필드는 필수이며 빈 목록은 형식상 허용합니다. 정상은 OK, 오류는 필드 경로를 사전순으로 쉼표 연결합니다. 구조 파싱 오류는 ERROR입니다. 해시 형식 검사는 파일 내용 일치 검증이 아닙니다.
모범 답안
import json,sys,re,math
def inspect(obj):
m=obj.get('manifest',{});events=obj.get('events');errors=[]
def need(ok,path):
if not ok: errors.append(path)
need(type(m.get('schema_version')) is int and m['schema_version']==1,'manifest.schema_version')
need(isinstance(m.get('code_version'),str) and bool(m['code_version'].strip()),'manifest.code_version')
need(type(m.get('seed')) is int,'manifest.seed')
dt=m.get('dt_s');need(type(dt) in (int,float) and math.isfinite(dt) and dt>0,'manifest.dt_s')
need(m.get('frame')=='map','manifest.frame')
need(m.get('units')=={'position':'m','yaw':'rad','time':'us'},'manifest.units')
for key in ('config','map','input'):
value=m.get('hashes',{}).get(key)
need(isinstance(value,str) and bool(re.fullmatch('[0-9a-f]{64}',value)),'manifest.hashes.'+key)
if not isinstance(events,list): return sorted(errors+['events'])
seen=set()
for i,e in enumerate(events):
if not isinstance(e,dict): errors.append(f'events[{i}]');continue
path=f'events[{i}].'
need(type(e.get('at_us')) is int and e['at_us']>=0,path+'at_us')
seq=e.get('seq');valid=type(seq) is int and seq>=0 and seq not in seen
need(valid,path+'seq')
if valid: seen.add(seq)
need(e.get('kind') in ('goal','sensor','tick'),path+'kind')
return sorted(errors)
try:
errors=inspect(json.load(sys.stdin))
print('OK' if not errors else ','.join(errors))
except (ValueError,TypeError,AttributeError): print('ERROR')
더 읽기
면접 질문
- 실험 기록을 재생할 때 함께 남겨야 할 정보를 설명해 주시면 됩니다.