Sphinx: 당신의 동네를 수수께끼로 바꾸다
요약
Sphinx는 사용자의 동네 지도를 수수께끼 산책로로 변환하는 앱입니다. 마을 이름을 입력하면, 지도상의 실제 사물들(조각상, 명판 등)을 찾아내고, 이를 묘사하는 두 줄짜리 수수께끼를 생성합니다. 이 경험은 '발견' 자체에 초점을 맞추어 사용자에게 사실적 정보를 제공하며, 앱 체류 시간보다 현장 탐험 시간을 중요하게 여깁니다.
핵심 포인트
- 지도를 수수께끼 산책로로 변환하여 지역의 숨겨진 역사적 사실을 발견하게 합니다.
- 실시간 OpenStreetMap 데이터를 기반으로 하며, 오프라인에서도 작동합니다.
- 경쟁 요소(점수판, 리더보드)를 제거하고 '사실' 발견 자체에 보상을 둡니다.
- 앱 체류 시간 대신 사용자가 현장에서 사물을 찾는 시간(time-to-pocket)을 핵심 지표로 삼습니다.
제가 만든 것
화면에는 한 줄이 표시됩니다:
나는 돌이고, 평평하며, 여기에 쓰여 있다. 나에 적힌 날짜 중 하나는 1956년이다.
그러다가 사라집니다. 휴대폰을 주머니에 넣고 아마다바드(Ahmedabad)의 성벽 안에 있는 비석으로 걸어가면 그것을 발견합니다. 휴대폰은 당신이 올바른 것을 보고 있는지 확인시켜 주고, 그 비문에 실제로 무엇이 쓰여 있는지 알려줍니다.
Sphinx는 당신 동네 지도를 수수께끼 산책로로 바꿉니다. 마을 이름을 입력하면, 거기에 지도에 표시된 실제 사물들 — 조각상, 청색 명판(blue plaques), 식수대, 전쟁 기념비, 계단 우물(stepwells) — 을 찾아내고, 도착했을 때 자신의 눈으로 확인할 수 있는 것만을 묘사하는 두 줄짜리 수수께끼를 각각 작성합니다. 신호가 없는 상태로 걸으며 각 사물을 찾고, 아마도 무심히 지나쳤을 법한 장소에 대한 실제 사실 하나를 해제합니다.
그리고 반전이 있습니다. 모든 정류장은
어린이를 청중으로 의도적으로 선택했고, 이 점이 거의 모든 결정에 영향을 미쳤습니다. 점수판도 없고, 연속 기록도 없고, 리더보드도 없고, 알림도 없고, 축하 폭죽도 없습니다. 무언가를 발견하는 보상은 바로 '사실'입니다. 20초 동안 읽은 후에는 화면이 거의 검정에 가깝게 어두워지도록 허용되는데, 이는 황혼에 거니는 아이가 빛나는 휴대폰을 바라보고 있는 모습으로는 풀밭(grass)과 접촉하고 있다고 볼 수 없기 때문입니다. 제품의 핵심 지표는 앱 체류 시간이 아니라 주머니 속으로 들어가는 시간(time-to-pocket)입니다.
데모 (Demo)
이 데모는 실제 사냥(hunt)이며, 실시간 OpenStreetMap 데이터를 기반으로 구축되었습니다. npx tsx scripts/hunt.ts "Ahmedabad, Gujarat, India" 명령을 실행했을 때 17개의 후보지와 7개의 플레이 가능한 정류장이 반환되었고, 이 사냥 내용은 리포지토리의 data/riddles/ahmedabad.json 파일에 커밋되어 있습니다. 영상 속 수수께끼들은 샘플 텍스트가 아닙니다.

이것을 단순한 기획 발표가 아닌 데모로 만드는 세 가지 요소가 있습니다:
- API 키, 계정, 네트워크 연결 없이 작동합니다. 사냥 콘텐츠는 리포지토리에 내장되어 IndexedDB에 시드(seed)되었으며, 동일한 플레이어가 동일한 동의 게이트 뒤에서 읽습니다. 제품과 코드가 공유되지 않는 데모는 아무것도 증명하지 못합니다.
- 주장하는 것이 아니라 브라우저에서 측정됩니다: 로딩 시 8개의 요청, 크로스 오리진(cross-origin) 요청은 0개입니다. 전체 플레이 루프 동안 어떠한 종류의 요청도 없습니다.
- 이 게시물에 나오는 모든 숫자는 이 리포지토리에서 생성되었으며,
npm run check:claims를 실행하면 그중 하나라도 벗어나면 빌드가 실패합니다.
어디서 작동하는가. GitHub Pages 빌드는 정적 내보내기(static export)입니다. 이미 완성된 브리스톨, 애마데드, 매디슨 사냥은 사진 확인의 저화질 경로를 포함하여 전체 오프라인으로 재생됩니다. 라이브한 사냥을 위해서는 Overpass로 연결되는 /api/hunt 프록시가 필요한데, 이는 정적 내보내기에는 존재하지 않습니다. 이 기능은 로컬에서 npm run dev를 실행해야 합니다. 배포된 데모는 의도적으로 인프라가 필요 없는 부분만 보여줍니다.
플레이 루프 (The play loop)

반전 (The twist)
아이에게는 **
| Suite | 무엇을 증명하는가 |
|---|---|
check:safety | 125개의 단언(assertions). 수수께끼는 움직임을 지시하지 않으며, 목표를 이름 짓지 않고, 사실을 발명하지 않는다. 동의 게이트는 작동한다. 낯선 사람 스크립트는 온전하다. |
| ... | |
가장 자랑스러운 것은 check:safety가 UI 소스 파일을 읽는다는 점이다. src/lib에 존재하는 규칙이라도 렌더링되지 않으면 안전 기능이 아니므로, 단언 중 26개는 컴포넌트를 열어 제어가 실제로 화면에 있는지 확인한다—즉, 리뷰 화면이 {stop.riddle} 및 {stop.fact}를 렌더링하고, slice(나 substring(, 그리고 자식 대상 컴포넌트가 부모 패널로 연결되지 않는지 확인한다. 마지막 점검은 하드코딩된 목록 대신 모든 컴포넌트를 스캔하는데, 첫 버전에서는 네 개의 파일만 이름 지어주고 다섯 번째는 조용히 무시했기 때문이다. |
구축 과정 (How I Built It)
각 단계가 실행 가능하도록 만든 여덟 번의 증분(increments)을 거쳤다. 왜냐하면 아이들을 위한 것을 한 번에 만들려고 하면 제품이라기보다는 데모를 배포하는 방식이기 때문이다. 모든 버그를 포함한 전체 추론 과정은 PROJECT_CONTEXT.md와 docs/roadmap.md에 있다.
데이터셋이 거의 모든 것을 오염시켰고, 나는 그것을 보지 못했다
파인튜닝(fine-tune)에는 데이터가 필요했으므로, 네 대륙의 14개 도시—브리스톨, 프라이부르크, 위트레흐트, 류블랴나, 웰링턴, 포틀랜드, 매디슨, 에든버러, 크라쿠프, 겐트, 발파라이소, 생프로방스-엑스, 포르투, 빌바오—를 휩쓸었고 568개의 실제 OSM 객체와 29개의 수작업으로 작성된 하우스 보이스 시드(house-voice seeds)를 가져왔다. (15개가 시도되었으나, 이 데이터셋을 생성한 빌드에서 탈린이 Overpass 요청에 실패하여 포함되지 않았다.) 많은 도시가 필요한 이유는 완성물(completions)이 아니라 프롬프트(prompts)가 다양하기 때문이다: 브리스톨의 태깅 습관은 포틀랜드와 다르며, 한 도시의 관습으로 훈련된 모델은 다른 도시를 자신 있게 잘못 설명할 것이다.
첫 번째 빌드는 94%의 깨진 행을 생성했다—총 443개 중 415개:
prompt: category = tourism:artwork
target: RIDDLE: 누군가 나를 청동에서 추방했어... 나는 1733년부터 여기에 서 있었지.
프롬프트는 청동(bronze), 예술가, 또는 1733에 대해서는 아무것도 언급하지 않습니다. 이것으로 학습하면 모델은 사실을 지어내도록 학습합니다. — 이 프로젝트가 막기 위해 존재하는 유일한 실패 사례이며, 출력물은 괜찮아 보이기 때문에 눈에 띄지 않습니다. 원인은 조회 누락(lookup miss)이었습니다: composeHunt는 후보 풀(candidate pool)의 순서를 재배열하고 제 ID 맵(id map)은 이 재배열된 배열을 기반으로 구축되었기 때문입니다.
이제 세 가지 가드(guard)가 존재합니다. 왜냐하면 경고만 하는 검사도 무시되는 검사이기 때문입니다. 조회 누락 시 건너뛰기(Skip on lookup miss). 타겟에 있는 어떤 숫자가 프롬프트에서 없는 경우 건너뛰기, 왜냐하면 숫자는 아이가 눈으로 확인하는 것이기 때문입니다. 그리고 빌드를 중단하고 위반 항목을 출력하는 최종 게이트가 있습니다.
이 세 번째 게이트는 제가 직접 작성한(hand-written) 시드에서도 제 규칙을 어긴 것을 즉시 포착했습니다. '나는 1901년에 올라갔다'라고 적힌 기념비적 수수께끼였는데, 자체 태그에는 연도가 없었습니다. 저는 가드를 작성하면서 같은 파일에서 이를 위반했습니다. 직접 작성한 데이터는 생성된 데이터보다 안전하지 않습니다. 이제 모든 시드는 생성된 텍스트와 동일한 125개의 단언(assertions)을 통과합니다.
파인튜닝 (The fine-tune)
Qwen/Qwen3-8B, LoRA rank 32, lr 2e-4, 2 epochs, batch 16, **477개의 학습 행(training rows)**을 사용하여 **120개의 비제출 객체(held-out)**에 대해 평가했습니다. OSM 객체를 기준으로 비제출했으며, 절대 행(row) 단위로 분할하지 않았습니다. 행 단위 분할은 동일한 기념비를 양쪽에 배치하여 일반화(generalisation)가 아닌 암기(memorisation)를 측정하게 만듭니다.
| 손실 (Loss) | 2.71 → 0.75 |
| ... | |
이 처리량 수치(throughput number)는 forward_backward와 optim_step을 연속으로 제출하고 그 둘 모두를 기다린(awaiting) 결과입니다. 이 사이에 대기하는 것은 단계당 3 클럭 사이클이 걸리며, 이는 이전보다 비용이 많이 드는 것입니다. 같은 결과를 얻지만 세 배의 비용이 발생합니다. 나중에 읽는 사람이 |
모델은 140개의 비문(inscription) 예시를 통해 '나에 있는 날짜 중 하나는 YYYY이다'라는 패턴을 학습한 후, 프롬프트에 날짜가 전혀 포함되어 있지 않은 경우에도 연도 슬롯을 채우는 현상을 보였습니다. 이는 슬롯-채우기 환각(slot-filling hallucination)이며, 아이가 절대 저질러서는 안 되는 실패 유형입니다.
저는 문턱을 낮추지 않았습니다. 측정하는 대상을 바꿨을 뿐입니다.

이제 모든 세대(generation)는 이미 통과한 동일한 린트 생성 수수께끼를 거칠 뿐만 아니라, **자신의 프롬프트에 대한 근거성 검사(groundedness check)**까지 거칩니다. 이 검사는 린트가 태그를 모르기 때문에 할 수 없는 검사입니다. 실패하는 모든 것은 오늘날 템플릿 틀이 되돌아가듯이, 템플릿 수수께끼로 되돌아갑니다. 따라서 표는 raw / shipped / baseline을 보고하며, 승자는 shipped 기준으로 결정됩니다. raw 기준으로 결정한다면, 아이에게 도달하지도 않은 세대에서 모델이 안전하다고 선언하는 것과 같습니다.
샘플링 온도(Sampling temperature)가 레버였고, 저는 저장된(saved) 체크포인트를 샘플링하여 이를 발견했기 때문에, 이 테스트는 훈련 비용이 들지 않았습니다:
| Temperature | Raw groundedness | Rescued | Specificity | Variety |
|---|---|---|---|---|
| 0.7 (default) | 98.8% | 3.3% | 84.2% | 91.7% |
| ... | ||||
| 아래 0.7에서는 복구율(rescue rate)이 절반으로 줄어듭니다. Greedy는 약간 더 반복적입니다. 결정론(determinism)은 모델이 변화시킬 수 있는 범위를 좁히기 때문에, 0.4가 0.0보다 우수합니다. |

모델이 실제로 이긴 것, 솔직하게 말하자면
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
