Skip to content

Instantly share code, notes, and snippets.

@sng2c
Created August 20, 2026 23:16
Show Gist options
  • Select an option

  • Save sng2c/2c99302333b0bed02ff4135f208259ef to your computer and use it in GitHub Desktop.

Select an option

Save sng2c/2c99302333b0bed02ff4135f208259ef to your computer and use it in GitHub Desktop.
State-Grid VM — spec.json authoring guide (for LLMs/humans)

State-Grid VM — Spec 저작 가이드 (for LLM / 인간)

코딩 LLM이 원하는 프로그램을 spec.json(순수 JSON, 호스트 코드 0)으로 작성하기 위한 규격. 플랫폼 빌트인 op만으로 완결 — 샌드박스·커스텀 JS 없이 상태머신을 선언한다.

본 가이드는 현재 런타임(platform/engine.mjs, ui/components.mjs)의 실제 동작에 맞춤. 예시는 그대로 spec.json 으로 저장 가능(JSON 유효).


1. 멘탈 모델

변형 Moore 머신. 프로그램 = 상태들의 그래프.

  • 상태(state) = {ui?, enter?, on}.
    • ui: 이 상태에서 렌더할 컴포넌트. 생략 시 transient(렌더 안 함 → 직전 화면 유지, no-flicker).
    • enter: 입장 액션 = op(빌트인 함수 1회 호출). → {event, envops} 반환.
    • on: 전이 테이블 {이벤트: 도착상태}. 이 상태에서 처리할 이벤트.
  • op 계약: enter 호출 → {event:문자열, envops:[[op,...],...]}.
    • envops = context 에 대한 선언적 쓰기(set/del/incr/decr/not).
    • event = 다음 전이 구동(on 으로 분기). event 없으면 정지(정류 상태).
  • 체인: 하나의 외부 이벤트가 enter → event → 전이 → enter → … 자동 연쇄(정류 상태까지).
  • context: {키:값} 객체.
    • persistent(기본): 체인 후에도 유지, 스냅샷에 포함.
    • request(#접두 키): 체인 종료 후 자동 제거, 스냅샷 제외. 이벤트 payload 주입용.
  • 제어 = 전이 테이블: 분기($ifeq 등의 결과 event 로 갈래), 시퀀스/루프(상태 그래프). op 는 부수효과(envops)만.

핵심: 로직은 op(순수 계산 + envops) + 전이 테이블(제어) 의 조합. 호스트 코드 불필요.


2. 최상위 스키마 (spec.json)

{
  "id": "unique-id",
  "version": 1,
  "label": "표시 이름",
  "initial": "IDLE",
  "context": { "count": 0 },
  "states": { "IDLE": { ... }, ... }
}
필드 필수 설명
id ✓ 스펙 고유 식별자(영속성 키).
version ✓ 스펙 버전(정수). 바뀌면 스냅샷 무효(재초기화).
label 드롭다운 표시용.
initial ✓ 진입 상태(states 의 키).
context ✓ 초기 context.
states ✓ 상태 맵(키=상태명).

3. 상태(state)

"STATE_NAME": {
  "ui": { "columns": 12, "gap": "12px", "components": [ ... ] },
  "enter": { "fn": "$add", "params": { "a": "{count}", "b": "1", "to": "count" } },
  "on": { "INC": "INC_OP", "DEC": "DEC_OP" }
}
  • ui 생략 → transient(계산만 하는 중간 상태).
  • enter 생략 → 정류(입장 액션 없음, 보통 결과 표시 상태).
  • on 에 없는 이벤트 수신 → drop(무시). 린터(Rule A)가 반환 이벤트는 미리 검증.

4. UI 컴포넌트

ui.components 배열. 각 원소: { "component": "종류", "grid": "1 / span 12", "props": {...}, "event": {...} }.

  • grid: CSS grid-column 값(예: "1 / span 12" 전체 너비, "1 / span 4" 1/3).
component props event 비고
Card title, desc — 정적 카드.
Metric label, key, unit? — ctx[key] 큰 글씨 표시 + unit.
ProgressBar progressKey, color? — ctx[progressKey] % 막대.
Button label, variant:"primary"|"secondary" {type, payload?} 클릭 시 이벤트 발생.
TextInput label, placeholder, bindingKey? {type} bindingKey ctx 양방향 바인딩; Enter 시 이벤트.

Button.event.payload: 객체. 각 키가 context 의 #키 로 주입(체인 후 제거). 예: {"type":"KEY","payload":{"kind":"digit","v":"7"}} → #kind, #v.


5. 이벤트 종류

발생원 형태 비고
사용자 Button 클릭 / TextInput Enter send({type, payload?}). payload → #키.
타이머 on 에 TICK_1000MS / TICK_5S 등 해당 주기마다 자동. 최소 간격 1개만 구독(demand clock).
BOOT 호스트가 start() 직후 1회 send({type:'BOOT'}) 부팅 작업 필요 시 on:{BOOT:...}. 미사용 시 drop(비침투적).
반환 enter op 의 결과 event 전이 테이블로 분기.

6. enter (op 액션)

"enter": { "fn": "$빌트인명", "params": { ... } }
  • params 값은 문자열. expand 치환:
    • {key} → ctx[key] (없으면 "").
    • {#key} → request 키.
    • 정규식 수량자 {n,m}/{n} 치환 안 함(리터럴 보존) — $regex 패턴 안전.
  • 반환 = {event, envops}. event 는 on 에 있어야(없으면 정지). envops 로 context 갱신.

7. 빌트인 함수 (BUILTINS)

모두 {event, envops} 반환. event 는 전이 구동, envops 는 context 쓰기. events 열 = 린터가 검증하는 반환 가능 이벤트(Rule A).

fn params events 동작
$pass {event} 동적(event 값) 주어진 event 그대로 반환(분기/시퀀스용).
$ifeq {a,b} true,false a===b(문자열 비교).
$ifne {a,b} true,false a!==b.
$iflt $ifgt $iflte $ifgte {a,b} true,false,error 수치 비교(비숫자→error).
$set {key,value} ok ctx[key]=value.
$log {...} ok console.log(디버그).
$add $sub $mul {a,b,to} ok,error ctx[to]=a±*/b(비숫자→error).
$div {a,b,to} ok,error ctx[to]=a/b; b=0→error(div_zero).
$concat {a,b,to} ok ctx[to]=String(a)+String(b).
$http_get {url,to} ok,error async fetch; ok→ctx[to]=body, error→ctx.__error(HTTP/네트워크).
$regex {from,pattern,to,group?,flags?} ok,error from에서 정규식 매칭→캡처(group 기본 1, 0=전체 매치)→ctx[to]; no match/잘못된 정규식→error.

$http_get 은 async(엔진이 thenable await). CORS 허용 엔드포인트 필요(브라우저 fetch). $regex 의 pattern 은 JSON 문자열 — 역슬래시는 \\ 로(예: "(\\d+\\.\\d+\\.\\d+\\.\\d+)").


8. envops (op 가 반환하는 context 쓰기)

빌트인이 알아서 구성. (커스텀/원격 fn 에서 직접 쓸 때 참고.)

형태 동작
["set", key, val] ctx[key]=val
["del", key] delete ctx[key]
["incr", key] ctx[key] += 1
["decr", key] ctx[key] -= 1
["not", key] ctx[key] = !ctx[key]

9. 커스텀 / 원격 fn (spec.functions 선언 — spec.json 가능)

spec.json 도 최상위 functions 필드로 fn 선언 가능(엔진이 spec.functions 를 읽는다):

"functions": {
  "$dbl":   { "code": "function (p) { return { event: 'ok', envops: [['set', p.to, Number(p.v) * 2]] }; }", "spec": { "events": ["ok"] } },
  "$myip":  { "remote": "https://host/fn", "spec": { "events": ["ok", "error"] } }
}
형태 필드 동작
커스텀 {code, spec} code = 전체 함수 소스(문자열). 엔진이 new Function('return ('+code+')')() 로 평가 → (params) => {event,envops}. 호스트 스레드(신뢰 spec).
원격 {remote, spec} 엔진이 POST params(JSON) → 응답 {event,envops} JSON 수신(로컬 fn 과 동일 계약).
(.mjs 전용) {fn, spec} 실제 함수 객체. JSON 불가.
  • spec.events 선언 필수(린터 Rule A 검증용).
  • code 는 신뢰할 수 있는 spec(작성자=사용자/LLM)에서만 — 호스트에서 평가(eval). 신뢰 불가하면 원격 fn(서버 측) 사용.
  • 빌트인 op 체인으로 풀 수 있으면 functions 없이 순수 빌트인이 가장 깔끔(정적 분석 용이).
  • 엔진은 spec.functions(JSON) + 호스트 opts.functions(.mjs) 병합. 순수 JSON 스펙은 spec.functions 만으로 자급식.
  • JSON 스펙으로 위 기능이 필요하면 → 빌트인 op 체인으로 풀 수 있는지 먼저 검토(대부분 가능).

10. 린터 (정적 검증) — 작성 후 반드시 통과

  • Rule A: op 상태(enter 있는 상태)의 fn 이 반환 가능한 모든 event 가 on 에 있어야.
    • 빌트인 events = 고정(BUILTIN_SPECS). 커스텀/원격 = spec.events 선언.
    • 예: $ifgte → on 에 true,false,error 세 갈래 모두 필요(미처리 event → 에러).
  • Rule B: 모든 상태는 initial 에서 도달 가능해야(dead state 금지).

11. 영속성

  • 체인 끝(최종 정류 상태) 에서 스냅샷 1회 저장. transient 상태(중간)는 스냅샷 미포함 → 빈 화면/하이드레이션 이슈 없음.
  • # request 키는 스냅샷 제외(재부팅 시 transient 안 남음).
  • 리셋: 현재 spec 스냅샷 + KV 삭제 → 재부팅(initial + 초기 context).

12. 예제

12.1 최소 카운터 ($add/$sub, 순수 빌트인)

{
  "id": "mini-counter",
  "version": 1,
  "label": "🔢 Counter",
  "initial": "IDLE",
  "context": { "count": 0 },
  "states": {
    "IDLE": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Metric", "grid": "1 / span 12", "props": { "label": "COUNT", "key": "count" } },
        { "component": "Button", "grid": "1 / span 6", "props": { "label": "+", "variant": "primary" }, "event": { "type": "INC" } },
        { "component": "Button", "grid": "7 / span 6", "props": { "label": "−", "variant": "secondary" }, "event": { "type": "DEC" } }
      ] },
      "on": { "INC": "INC_OP", "DEC": "DEC_OP" }
    },
    "INC_OP": { "enter": { "fn": "$add", "params": { "a": "{count}", "b": "1", "to": "count" } }, "on": { "ok": "IDLE", "error": "IDLE" } },
    "DEC_OP": { "enter": { "fn": "$sub", "params": { "a": "{count}", "b": "1", "to": "count" } }, "on": { "ok": "IDLE", "error": "IDLE" } }
  }
}

INC_OP/DEC_OP 는 ui 없음(transient) → 계산 후 IDLE 재렌더(숫자 갱신). $add/$sub 는 ok/error 반환 → error 도 on 에 있어야(Rule A). 여기선 계산 실패 시 IDLE 복귀(카운트 유지).

12.2 공인 IPv4 조회 ($http_get → $regex)

{
  "id": "myip",
  "version": 1,
  "label": "🌐 My IP",
  "initial": "IDLE",
  "context": { "ip": "…", "__error": "", "raw": "" },
  "states": {
    "IDLE": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Card", "grid": "1 / span 12", "props": { "title": "My Public IPv4", "desc": "버튼 클릭" } },
        { "component": "Button", "grid": "1 / span 12", "props": { "label": "Get IPv4", "variant": "primary" }, "event": { "type": "FETCH" } }
      ] },
      "on": { "FETCH": "FETCH" }
    },
    "FETCH": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Card", "grid": "1 / span 12", "props": { "title": "조회 중…", "desc": "" } }
      ] },
      "enter": { "fn": "$http_get", "params": { "url": "https://ipv4.icanhazip.com", "to": "raw" } },
      "on": { "ok": "PARSE", "error": "ERROR" }
    },
    "PARSE": {
      "enter": { "fn": "$regex", "params": { "from": "{raw}", "pattern": "(\\d+\\.\\d+\\.\\d+\\.\\d+)", "to": "ip" } },
      "on": { "ok": "SHOW", "error": "ERROR" }
    },
    "SHOW": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Metric", "grid": "1 / span 12", "props": { "label": "IPv4", "key": "ip" } },
        { "component": "Button", "grid": "1 / span 12", "props": { "label": "다시 조회", "variant": "secondary" }, "event": { "type": "FETCH" } }
      ] },
      "on": { "FETCH": "FETCH" }
    },
    "ERROR": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Metric", "grid": "1 / span 12", "props": { "label": "error", "key": "__error" } },
        { "component": "Button", "grid": "1 / span 12", "props": { "label": "재시도", "variant": "primary" }, "event": { "type": "FETCH" } }
      ] },
      "on": { "FETCH": "FETCH" }
    }
  }
}

FETCH(async) → PARSE(정규식 추출/검증) → SHOW. CORS 허용 + IPv4-only 엔드포인트 사용.

12.3 인사 폼 (TextInput + $ifeq 분기 + $concat)

{
  "id": "greet",
  "version": 1,
  "label": "👋 Greet",
  "initial": "IDLE",
  "context": { "name": "", "greeting": "" },
  "states": {
    "IDLE": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "TextInput", "grid": "1 / span 12", "props": { "label": "이름", "placeholder": "이름 입력 후 Enter", "bindingKey": "name" }, "event": { "type": "SAY" } },
        { "component": "Card", "grid": "1 / span 12", "props": { "title": "Greet", "desc": "이름을 넣고 Enter" } }
      ] },
      "on": { "SAY": "CHECK" }
    },
    "CHECK": { "enter": { "fn": "$ifeq", "params": { "a": "{name}", "b": "" } }, "on": { "true": "EMPTY", "false": "BUILD" } },
    "EMPTY": {
      "enter": { "fn": "$set", "params": { "key": "greeting", "value": "이름을 입력하세요" } },
      "on": { "ok": "IDLE" }
    },
    "BUILD": {
      "enter": { "fn": "$concat", "params": { "a": "Hello, ", "b": "{name}", "to": "greeting" } },
      "on": { "ok": "SHOW" }
    },
    "SHOW": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Metric", "grid": "1 / span 12", "props": { "label": "Greeting", "key": "greeting" } },
        { "component": "Button", "grid": "1 / span 12", "props": { "label": "다시", "variant": "secondary" }, "event": { "type": "RESET" } }
      ] },
      "on": { "RESET": "IDLE" }
    }
  }
}

CHECK($ifeq) → 빈 이름이면 EMPTY($set 경고문 → IDLE), 아니면 BUILD($concat → SHOW). 분기 = 전이 테이블.


13. 작성 템플릿

{
  "id": "<unique-id>",
  "version": 1,
  "label": "<표시명>",
  "initial": "<초기상태>",
  "context": { "<키>": "<초기값>" },
  "states": {
    "<초기상태>": {
      "ui": { "columns": 12, "gap": "12px", "components": [
        { "component": "Button", "grid": "1 / span 12", "props": { "label": "Go", "variant": "primary" }, "event": { "type": "DO" } }
      ] },
      "on": { "DO": "<NEXT>" }
    },
    "<NEXT>": {
      "enter": { "fn": "<$빌트인>", "params": { "<p>": "<값 또는 {키}>" } },
      "on": { "<event>": "<TARGET>" }
    }
  }
}

14. LLM 작성 체크리스트

  1. id 고유, initial 은 states 의 키.
  2. Rule A: 모든 enter fn 의 반환 event 가 on 에 존재.
    • $ifgt 계열 → true/false/error 3개 모두 on 에.
    • $http_get/$regex → ok/error 2개.
    • $add/$set/$concat → ok.
  3. Rule B: 모든 상태가 initial 에서 도달 가능(사용자/타이머/BOOT/반환 이벤트 경로).
  4. params 값은 문자열; {key}/{#key} 치환. $regex 수량자 {n,m} 안전.
  5. 계산 상태는 ui 생략(transient); 결과 표시 상태는 ui + enter 생략(정류).
  6. Button 이벤트: {"type":"...","payload":{...}}. payload → #키.
  7. 타이머: on 에 TICK_<n>MS/TICK_<n>S. 부팅 작업: on:{BOOT:...}.
  8. $http_get 은 CORS 허용 URL; IPv4 강제 필요 시 IPv4-only 호스트(ipv4.icanhazip.com).
  9. 커스텀/원격 fn 필요 시 spec.functions 선언(code/remote, JSON 가능). 빌트인 체인으로 풀리면 가급적 순수 빌트인.
  10. 작성 후 린트 통과 확인(Rule A/B).

15. 설계 원칙 요약

  • op = 순수: fn 은 (params) → {event, envops}. context 직접 안 쓰고 envops 로.
  • 제어 = 전이 테이블: 분기/시퀀스/루프는 상태 그래프로.
  • 부수효과 = envops: 선언적 쓰기(set/del/incr/decr/not).
  • 읽기 = param expand: {key} 치환.
  • 외부 I/O = 빌트인: $http_get(fetch), $regex(추출). 커스텀/원격은 .mjs.
  • 호스트 코드 0: spec.json 은 순수 선언 → 샌드박스 불필요, 정적 검증 가능.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment