Skip to content

Latest commit

 

History

History
650 lines (506 loc) · 34.6 KB

File metadata and controls

650 lines (506 loc) · 34.6 KB

Part Engine — 범용 부품 조립 시스템 (도메인 무관)

범용 층만 담는다 — 도메인 지식(첫 적용: LLM 프롬프트 파이프라인)은 별도 crate로. 목적: 다른 프로젝트(다른 템플릿 언어, 설정 생성기, 컴파일러 파이프라인)에서 재사용.

핵심 아이디어 = 로봇 조립 키트

서브모터(180°), 메인모터(360°), LED, 고휘도 LED, 센서 — 능력이 부품에 새겨져 있다.

  • 능력 차이 = 설정 슬라이더가 아니라 부품의 정체성 (LED vs 고휘도 LED는 다른 부품)
  • 커넥터 모양이 다르면 안 꽂힘 = typed connector — 잘못된 조립이 표현 불가능 (correct by construction)
  • 부품이 유한해서 재밌다 = 표현력 제한은 결점이 아니라 요점

첫 도메인의 초기 어휘 후보 (미확정 — 기존 사용 패턴에서 추출):

  • 센서: 키워드 센서, 패턴 센서(regex), 깊이 센서, 토큰량 센서
  • 모터(동작): 일반 주입, 고정 주입(항상 상단), 블록 내 치환, 이동 치환

2층 구조 — 우회 없음

1층: 기성 부품 조립       — 일반 유저
2층: 부품 제작            — 고급 유저, 단 도메인 안에서만
없음: 시스템 우회          — 표현 불가능

부품 정의 = 인터페이스(typed connector·capability 선언) + 본체(DSL — 도메인 연산만 표현 가능). 본체 언어에 위험 구문이 문법 자체에 존재하지 않으므로 UB가 원리적으로 불가능하다 — 에러 처리로 막는 게 아니라 문법·타입에서 제거. 부품은 자기완결 데이터라 공유/배포 가능 (부품 팩 생태계).

아키텍처 — crate 2분할 (재사용의 조건)

part-engine  (도메인 무관): typed connector 그래프 + 조립 검증
                            + capability 샌드박스 + IR 파이프라인 실행기
                            — IR은 trait으로 추상화, 도메인을 모름
<domain>-parts (도메인별) : 도메인 IR + 도메인 함수 + 부품 어휘
  • 파이프라인 = "IR을 받아 IR을 반환하는 단계들"의 조립 — 컴파일러 pass 파이프라인과 동형 (nanopass). 응용: 템플릿 언어, 설정/문서 생성기, 트랜스파일러, ETL.
  • 도메인 타입이 실행기에 스며들면 나중 분리는 사실상 재작성 — 경계는 처음부터.

DSL — 전용 문법 (Rust + LALRPOP)

부품 선언·조립·본체·조건식을 한 문법으로 통일. (검토했던 대안: minijinja 차용 — 표기가 3벌로 분산되고 샌드박스가 "함수 비노출" 수준에 그쳐서 기각. 전용 문법은 샌드박스가 문법 수준에서 성립.)

  • 성장 규율: 표현력이 부족하면 답은 문법 확장이 아니라 그 괴로움을 도메인 함수 하나로 승격하는 것. 문법은 작게 고정, 어휘로 성장 — 이 규칙이 non-UB를 유지한다.
  • 패턴 매칭은 rust regex (RE2 계열): 선형 시간 보장 → ReDoS 구조적 불가능. lookaround/backref 없음은 의도된 교환.
  • LALRPOP 실무 주의: ① 템플릿류는 "생 텍스트 + 섬" 구조라 커스텀 lexer(모드 전환) 필요, ② 에러 진단 품질(error recovery + 친절한 메시지)은 별도 공사이며 최종 유저 경험을 좌우.
  • Rust 한 crate → 서버 native + 브라우저 WASM 양쪽 배포.

무엇이 아닌가 — 스크립트 언어와의 경계

mollu는 스크립트 언어가 아니다 — 임의 코드 실행이 정의상 불가능하다. 정확한 분류는 조립 기술 언어(assembly description): 선언 + 연결 + 제한된 표현식. 텍스트 안에 섬({{…}})이 있다는 표면 때문에 템플릿 매크로처럼 보이지만, 표현식이 언어로 자라는 경로가 문법에 없다.

기존 4종 대비 위치

기존 물건 mollu에서의 운명
템플릿 치환 매크로류 표현식/치환 정당한 핵심(지연 참조)만 섬 표현식이 흡수. 제어흐름·상태변경은 비흡수 — 그 요구는 조립 구조 소관
regex 규칙 목록 변환 규칙 패턴 센서 부품으로 흡수 (rust regex가 능력으로 새겨짐)
이벤트 자동화 (trigger류) 감지→행동 감지→주입 절반만 센서→모터 조립으로 흡수. 부수효과 실행 절반은 의도적 비흡수
범용 임베디드 언어 (Lua류) 아무거나 대체 없음, 영구 공백 — 그 자리가 존재하면 안 된다는 것이 테제

기존 시스템들의 실패는 언어가 약해서가 아니라 범용이어서다 — 넘치는 요구가 아무 표면에나 자라며 4종이 섞였다(매크로에 제어흐름, 자동화에 로직, 규칙에 양방향 변환). 분류 기준을 처음부터 세운다: 신뢰 경계를 넘는가(카드에 실리는가) × 능력이 어디 새겨지는가(부품 vs 코드). 표현력이 부족해지면 문법이 아니라 부품 어휘가 자라는 규율이 같은 혼합의 재발을 막는 방화벽이다.

crate 경계 = 샌드박스의 두 번째 층

mollu-lang    텍스트 표기 → AST        — "읽는 법"만 안다. 실행기가 없다.
mollu-engine  조립 검증 + 평가          — "돌리는 법"만 안다. 등록된
                                          capability 함수만 호출 가능.

범용 언어 crate를 넣으면 그 안에 인터프리터(임의 코드 실행기)가 들어온다. mollu에는 그에 해당하는 물건이 워크스페이스 어디에도 없다: lang은 위험한 것을 파싱할 수 없고(문법 수준 샌드박스), engine은 위험한 것을 실행할 수단이 없다(capability 닫힘). "위험 구문이 문법에 부재"가 crate 경계로도 한 번 더 강제된다.

UI 원칙 (범용) — 결과물 직접 조작

유사 코드(조건 드롭다운, if-then 폼) 금지. 최종 산출물의 실물이 눈앞에 있고, 그 위에서 직접 조작한다 — 조작이 먼저, 조건("언제 적용?")은 부속으로 나중에. 내부 그래프/IR은 숨은 실행 표현이며, 조작이 IR 변형으로 기록된다. 이를 위해 조립체 = 직렬화 가능한 데이터 (코드 아님)가 필수 제약.

Anti-goal — 노드 캔버스 UI (ComfyUI 방식) 금지. 그래프(실행 표현)를 유저에게 노출하는 것은 유사 코드 원칙 위반이며, 팬/줌 캔버스·와이어·좁은 포트는 터치와 상극 (모바일 불가의 뿌리). 표면은 도메인의 자연 형태를 따른다 — 산출물이 선형 문서면 UI도 문서형(스크롤 한 축 + 탭/드래그). "센서를 모터에 꽂기"는 캔버스 배선이 아니라 부품 탭 → 슬롯에 끼우기. 모바일(터치) 퍼스트.

구현 단계 (비용 배분)

비용의 몸통은 UI다 — 언어가 아니라. 그래서:

1단계: 엔진 + 부품-as-데이터 + 본체 언어      ← 공통 토대
2단계: 텍스트 표기 (전용 문법)                ← 이것만으로 "UB 없는 DSL"로 완결
3단계: 직접조작 UI                            ← 여력·확신이 생겼을 때

1~2단계 대상 = 코드 가능한 파워 유저. 3단계가 "누구나" 층을 연다.

용어 매핑 (기성 개념)

  • block-based visual programming / node-based dataflow (분류상 친척 = ComfyUI, 단 UI 반면교사)
  • typed connectors, correct by construction, making illegal states unrepresentable
  • capability-based design, sandboxed DSL
  • end-user programming, user-defined components

한 줄: capability-sandboxed, block-based composition engine with a document-surface UI — 도메인은 crate로 갈아끼운다.


결정점 분석 — 문법 스케치 A/B와 5개 결정점 (2026-07-04)

상태: 전 결정점 미승인 — 아래의 "분석 결론"은 전부 제안이다. 각 선택지 아래에서 실제 문법 조각을 구성하고, LALRPOP 실현 비용과 규율 정합을 도출했다. 확정은 설계자의 선택으로만 일어난다.

현재 상태 요약 (2026-07-06 갱신)

결정점 분석 결론 (제안 — 미승인) 상태
① 선언 형태 A/B/혼합 ③ 확정 후 취향 결정으로 축소. 진단 국소성·UI 상호 변환은 A 우위, 밀도는 B 우위 대기
② sensor/motor 키워드 범주 닫힘 미검증 → kind 식별자안(b)이 규율 동형 대기 — 선행: 범주 닫힘 판정 (초기 어휘 확정 시)
③ 본체 텍스트 경계 가변 길이 펜스(c)만 구조적 결손 없음 — body{}·들여쓰기는 탈락 사유가 구조적 대기 — 잔여 자유도: 펜스 문자
④ 섬 표현식 상한 "참조+호출"(b)이 규율에서 도출되는 유일 상한 대기
⑤ 한국어 식별자 허용 + NFC 동일성 — 5개 중 비용 최소 대기

결정점과 독립적으로 도출된 것 (어느 선택지를 골라도 성립 — 근거는 이하 본문):

  • lexer 3모드(CODE/BODY/ISLAND)는 상수다 — 결정점이 아니다.
  • 지연 해석 불변식 — 참조는 파싱 시 치환이 아니라 평가 시 Env 해석. 현재형 witness = 그룹챗(발화자 축), 미래형 = 멀티유저 시점 조립(시점 축).
  • fan-out 구문의 실수요 기각 — 관찰된 다중성은 전부 "한 조립 × N Env 평가 루프"(엔진 소관, 문법 밖)로 수용된다. 잔여는 그래프 분기뿐, 실수요 미관찰.
  • regex는 표기에 불투명 문자열 토큰으로만 등장 — lexer 모드를 늘리지 않는다 (말미 "경계 확인" 절).
  • 결정 순서는 ③ → ② → ① → ④ → ⑤가 경제적이다 (상류가 하류의 선택지를 줄임).
  • 스크립트 언어와의 경계 — mollu는 조립 기술 언어이며 레거시 치환 문법과 같은 자리에 서되 그 성질(즉시 치환·구현=스펙·우회 가능)을 부정한다. §"무엇이 아닌가"에 확정 정리됨.

평가축 (공통)

내용
(a) LALRPOP 비용 LR(1) 충돌 여부, 커스텀 lexer 모드 수, 에러 진단 품질
(b) 규율 정합 작은 문법 고정 · 위험 구문 부재 · 성장은 어휘(capability)로
(c) 제작자 경험 읽기/쓰기 부담, 모바일(좁은 화면) 가독 — 메인 환경이 Android다

LALRPOP 전제 사실 (0.22 기준):

  • LR(1) — 충돌은 빌드 시점에 보고된다. 아래 "충돌 없음" 판정은 각 조각의 구조(모든 분기가 서로 다른 선두 토큰으로 시작)에서 도출한 것이고, 최종 확인은 실제 .lalrpop 빌드가 witness다.
  • 커스텀 lexer = extern { enum Tok } 선언 + Iterator<Item = Result<(Loc, Tok, Loc), E>> 구현. 모드 전환은 lexer 상태만으로 결정 가능해야 한다 — 파서→lexer 피드백 (lexer hack)은 LR(1) lookahead와 얽혀 취약하므로 배제한다. 이 제약이 아래 분석 전체를 관통한다: "이 문법에서 lexer가 지금 어느 모드인지 스스로 아는가?"
  • 에러 회복은 ! error 토큰 — 회복 anchor(동기화 지점)가 문법에 얼마나 있느냐가 진단 품질을 좌우한다.

lexer 모드는 모든 선택지에서 최소 3개다 (이건 결정점이 아니라 상수):

CODE   — 구조부 (선언·조립·섬 표현식과 동일 토큰)
BODY   — 생 텍스트 (본체). 오직 섬 여는 표기와 본체 닫는 표기만 인식
ISLAND — {{ }} 내부. CODE와 같은 토큰 집합, 닫는 }}에서 BODY로 복귀

결정점 ③이 CODE↔BODY 전환 신호를, ④가 ISLAND의 문법 크기를 정한다.


① 선언 형태 — A(블록) vs B(시그니처) vs 혼합

A안 — 블록 선언형

part keyword_sensor {
  in    chat: Chat
  out   hit: Signal
  param words: List<Text>
  sense any(chat.last, words)
}

assembly main {
  keyword_sensor(words: ["비", "빗소리"]) -> inject_top
}
  • 문법 조각: PartDecl = "part" Ident "{" PartItem* "}", PartItem = InDecl | OutDecl | ParamDecl | SenseDecl | BodyDecl — 모든 항목이 서로 다른 키워드로 시작 → LR(1) 충돌 없음.
  • 진단: 항목 키워드가 회복 anchor다. 항목 하나가 깨져도 다음 in/out/param/sense/body에서 재동기화 — 부품 하나의 오류가 부품 하나로 국소화된다. "여기서 기대: in, out, param, sense, body 중 하나" 식으로 기대 집합이 열거 가능해 메시지가 친절해진다.
  • (c): 한 줄 = 한 사실. 좁은 화면에서 줄바꿈 없이 읽힌다.
  • 비용: 장황함. 슬롯 2~3개짜리 부품에도 5줄.

B안 — 시그니처형

sensor keyword_sensor(chat: Chat, words: List<Text>) -> Signal =
  any(chat.last, words)

motor inject_top(hit: Signal, text: Text) -> Prompt = ...

assembly main =
  keyword_sensor(words: ["비", "빗소리"]) |> inject_top(text: "…")
  • 문법 조각: SensorDecl = "sensor" Ident "(" Params ")" "->" Type "=" Expr — 역시 충돌 없음, 문법 생산 규칙 수는 A보다 적다.
  • 문제 1 — 본체가 붙을 자리가 없다. 센서(= 표현식)는 자연스럽지만, 모터의 본체는 생 텍스트다. = 뒤에 텍스트를 놓으면 lexer가 "표현식인지 생 텍스트인지"를 알 수 없다 — 모드 전환 신호가 없다. 결국 = """…"""처럼 ③의 경계 표기를 빌려와야 하고, 그 순간 B는 "시그니처 + 경계 표기"가 된다.
  • 문제 2 — 진단 국소성. 시그니처 한 줄에 이름·슬롯·타입·출력이 다 들어 있어, 중간의 오타 하나에 대한 오류가 줄 전체를 가리킨다. 회복 anchor는 선언 단위 (sensor/motor/assembly)뿐 — A의 항목 단위보다 굵다.
  • (c): 파라미터 2개를 넘으면 좁은 화면에서 시그니처가 접혀 A의 세로 나열보다 오히려 읽기 어렵다.
  • 장점: 짧다. 수학적 함수 직관("센서 = Chat을 받아 Signal을 내는 함수")과 일치.

혼합안 — 시그니처 머리 + 블록 몸통

sensor keyword_sensor(chat: Chat, words: List<Text>) -> Signal {
  sense any(chat.last, words)
}

motor inject_top(hit: Signal, text: Text) -> Prompt {
  body """
  {{ text }}
  """
}
  • 인터페이스는 B의 밀도, 본체·부가 항목(기본값, 문서화 주석)은 A의 블록.
  • 문법: Decl = Kind Ident "(" Params ")" "->" Type "{" Item* "}" — 충돌 없음.
  • 단, B의 문제 2(시그니처 줄의 진단 국소성·모바일 가독)는 그대로 상속한다.

도출

  • 세 안 모두 LR(1) 충돌·lexer 모드 수에서는 등가다. 실현 비용은 갈라지지 않는다 — 갈라지는 건 진단 국소성(A > 혼합 ≥ B)과 밀도(B > 혼합 > A)다.
  • 본체 경계(③)를 별도 표기로 확정하고 나면, "본체를 어디에 붙이나"라는 A/B의 구조적 차이는 소멸하고 인터페이스 표기 취향만 남는다. ③이 ①보다 상류 결정이다.
  • caveat: A의 장황함은 3단계 UI가 흡수한다(블록 = UI 폼과 동형이라 상호 변환이 직선적). B의 시그니처는 UI 폼으로 펼치려면 한 줄을 분해해야 한다 — 2단계 표기와 3단계 UI의 상호 변환 관점에서는 A가 가깝다.

② sensor/motor의 키워드 지위

(a) 전용 키워드 승격 — B/혼합안의 전제

sensor keyword_sensor(…) -> Signal { … }
motor  inject_top(…) -> Prompt { … }
  • 진단 최상: 첫 토큰이 부품 종류를 확정하므로 "motor에는 sense가 올 수 없다" 같은 종류별 검사를 파서 단계에서 할 수 있다.
  • 규율과의 긴장: sensor/motor는 프롬프트 도메인의 어휘다 (위 초기 어휘 후보 — "미확정"이 명시된 상태). mollu-lang은 도메인 무관 crate인데, 도메인 어휘를 문법 키워드로 구우면 crate 2분할 경계(엔진은 프롬프트를 모른다)가 문법 층에서 깨진다. 새 부품 범주 추가 = 문법 변경 = "어휘로 성장" 규율의 정면 위반.
  • 이 긴장이 성립하지 않는 유일한 가정: 부품 범주가 구조적 2분류(감지/동작)로 닫힌 집합이라는 것. 이 가정은 현재 미검증이다 — 초기 어휘가 미확정인 상태에서 범주의 닫힘을 단정할 근거가 없다.

(b) kind 항목 — 식별자로 선언, 의미 검사로 검증

part keyword_sensor {
  kind  sensor        ← sensor는 키워드가 아니라 식별자
  in    chat: Chat
  …
}
  • 문법은 kind Ident 하나로 고정. 유효한 kind 집합은 도메인 crate가 엔진에 등록하고, 조립 검증 시점에 미등록 kind를 거부한다 — capability 등록과 동형의 성장 경로다. 새 범주 = 등록 추가, 문법 불변.
  • 비용: 진단이 한 단계 늦어진다 (파서가 아니라 검증기가 잡음). 단, 검증기는 "등록된 kind: sensor, motor" 같은 후보 나열이 가능해 메시지 품질 자체는 유지된다 — 잡는 시점만 다르다.
  • lexer 이득: 예약어가 늘지 않는다. sensor라는 이름의 param도 자유.

(c) kind 추론 — 표기 자체를 제거

part keyword_sensor {
  in  chat: Chat
  out hit: Signal      ← 출력이 Signal이므로 센서로 추론
}
  • 문법 최소. 그러나 추론 규칙 "출력 타입 → 범주"가 단사(injective)라는 가정이 필요하다 — 같은 출력 타입을 내는 두 범주가 생기면 무너진다. 초기 어휘 미확정 상태에서 이 가정도 검증 불가. 진단도 간접적이다 ("왜 이게 센서지?"에 대한 답이 코드에 없음).

도출

  • branching assumption = 부품 범주가 닫힌 집합인가. 닫혔다고 확정할 수 있으면 (a)의 진단 이득이 실재하고, 확정할 수 없으면 (a)는 규율 위반이며 (b)가 규율과 동형이다. (c)는 어느 가정에서도 추가 전제(단사 추론)를 요구해 지배당한다(dominated).
  • (b)는 ①A/혼합의 블록과 자연 결합하고, (a)는 ①B의 전제다 — ②의 선택이 ①을 부분적으로 구속한다.

③ 본체 텍스트 경계 — body{} vs 들여쓰기 vs """

먼저 본체에 실제로 들어올 내용을 관찰한다 (프롬프트 도메인의 사실):

- JSON/코드 예시     → { } " 를 일상적으로 포함
- 마크다운 코드펜스  → ``` 포함
- 시·대사 인용        → 들여쓰기가 곧 내용
- 타 카드에서 복붙    → 임의의 들여쓰기·개행이 딸려 옴

∴ 평가 기준: "본문에 어떤 문자가 와도 이스케이프 없이 담기는가" — 이 성질이 없으면 제작자는 붙여넣기마다 본문을 수술해야 한다.

(a) body { … } — 중괄호 경계

body {
  당신은 {{ char.name }}입니다. 출력 형식: {"mood": "..."}
}                                          ↑ 이 } 가 경계를 조기 종료시킨다
  • 본문의 }와 경계의 }가 충돌 → lexer가 중괄호 깊이를 세거나 본문 }를 이스케이프해야 한다. 깊이 세기는 짝이 안 맞는 본문(:} 이모티콘, 반쪽 JSON 조각)에서 무너지고, 이스케이프는 복붙 수술이다. 관찰된 본문 내용과 정면 충돌 — 탈락 사유가 구조적이다.

(b) 들여쓰기 경계 (오프사이드 규칙)

body:
    당신은 {{ char.name }}입니다.
    출력 형식: {"mood": "..."}
  • lexer가 INDENT/DEDENT 가상 토큰을 발행 (Python 방식) — 커스텀 lexer로 가능은 하다. 그러나: ① 본문 내 들여쓰기(시·코드)와 경계 들여쓰기가 섞여 본문을 오염시킨다 — "본문 그대로" 성질 상실, ② 복붙된 텍스트의 들여쓰기를 항상 재조정해야 한다, ③ 탭/스페이스 혼용 진단이라는 별도 공사가 생긴다. lexer 모드도 3개에서 사실상 4개(INDENT 추적 상태)로 는다.

(c) """ 펜스 — 단, 가변 길이

body """
당신은 {{ char.name }}입니다.
출력 형식: {"mood": "..."}
"""

body """"          ← 본문에 """가 필요하면 펜스를 한 글자 늘린다
그는 """이라고 쳤다.
""""
  • lexer: 여는 펜스에서 따옴표 개수 n을 기억 → BODY 모드 → 줄 머리의 따옴표 n개에서만 닫힘. 상태가 정수 하나 — 깊이 세기도 이스케이프도 없다.
  • 가변 길이가 요점이다: 고정 """면 본문에 """가 올 때 (a)와 같은 충돌이 재발한다. 가변이면 임의 본문 ∀에 대해 담을 수 있는 펜스가 항상 존재한다 (본문이 유한하므로 본문 내 최장 따옴표 연속열 +1). 마크다운 코드펜스·Rust r#"…"#와 같은 구조 — 선례가 검증한 설계다.
  • 진단: 닫는 펜스 미발견 = "N행의 """가 닫히지 않았습니다" — 오류 지점이 명확하다. 들여쓰기 방식의 "보이지 않는 문자" 진단과 대비된다.
  • 남는 결정 하나: 본문 안의 섬 여는 표기 {{를 문자 그대로 쓰고 싶을 때의 탈출 (\{{ 또는 {{"{{"}}) — 어느 경계안을 골라도 공통으로 필요한 직교 결정이다.

도출

  • (a)는 관찰된 본문 내용과 구조적으로 충돌, (b)는 "본문 그대로" 성질을 잃고 lexer 공사가 최대. (c)-가변만이 세 축 모두에서 결손이 없다 — 이 결정점은 다른 넷과 달리 선택지 간 비대칭이 커서, 사실상 (c)의 채택 여부보다 "펜스 문자를 무엇으로 하나"(""" vs ``` vs ---)가 남은 자유도다.
  • 상류 효과: (c) 확정 시 ①의 A/B 구조 차이가 소멸한다 (§① 도출 참조).

④ 섬 표현식 상한 — 참조만 / +호출 / +파이프

세 상한 모두에서 대입·루프·재귀·eval은 문법에 부재 — 위험 구문 부재 축은 등가다. 갈라지는 것은 성장 규율의 작동 여부와 문법 크기다.

(a) 참조만

{{ user.name }}  {{ chat.last }}
문법: Island = "{{" Path "}}",  Path = Ident ("." Ident)*
  • 문법 최소 (생산 규칙 2개), ISLAND 모드 토큰 5종.
  • 성장 규율과의 모순이 도출된다: 규율은 "표현력 부족 → 도메인 함수로 승격" 인데, 참조만이면 승격된 함수를 본체에서 호출할 문법이 없다. 성장 경로가 본체에 한해 막힌다 — 승격의 목적지가 조립식(sense 절)뿐이라면, "대문자로" 같은 텍스트 국소 변형까지 부품 하나를 선언해야 한다. 규율이 예측하는 압력 (문법 확장 요구)이 가장 먼저 여기서 터질 것이다.

(b) 참조 + 호출

{{ upper(user.name) }}  {{ truncate(chat.last, 200) }}
문법: Expr = Path | Ident "(" (Expr ("," Expr)*)? ")" | Literal
  • 생산 규칙 +2, 충돌 없음 (호출은 Ident (로 선두 구분 — LR(1)에서 Path와의 구분은 lookahead 1로 충분).
  • 호출 대상은 capability 등록 함수로 닫혀 있다 (mollu-engine의 Capabilities::call — 미등록은 UnknownCapability, witness 테스트 존재). 즉 (b)가 성장 규율이 본체에서 작동하기 위한 최소 문법이다: 새 요구 = 함수 등록, 문법 불변.
  • 유저 정의 함수·람다는 여전히 문법에 부재 — 샌드박스 불변.

(c) 참조 + 호출 + 파이프

{{ chat.last |> trim |> truncate(200) |> upper }}
문법: Expr = PipeExpr,  PipeExpr = CallExpr ("|>" CallExpr)*
  • 좌결합 이항 연산 1개 추가 — 충돌 없음. 단 truncate(200)이 "첫 인자를 파이프에서 받는 부분 적용"이라는 탈설탕 규칙이 문법 밖 의미론으로 추가된다.
  • 같은 계산의 표기가 2벌이 된다 (upper(trim(x))x |> trim |> upper) — "작은 문법" 규율은 표기 중복도 비용으로 센다. 이득은 긴 체인의 가독성인데, 체인이 길어진다는 것 자체가 "부품으로 승격하라"는 규율의 신호다 — 파이프는 그 신호를 완화해 승격을 지연시키는 방향으로 작용할 수 있다.

시점(viewpoint) 요구와 상한 — 멀티유저 시점 조립 분기 반영 (2026-07-04 추가)

멀티유저 "참가자별 시점 조립" 분기(같은 채팅을 참가자별 시점으로 조립, viewpoint가 파이프라인 인자 — 이하 분기 B)가 ④에 주는 질문: 시점 의존 참조가 "참조만" 수준에서 표현 가능한가, 함수 승격이 필요한가. 두 경우를 나눠 구성한다.

시점 의존 "참조" — (a) 수준에서 이미 표현 가능:
  {{ user.name }}   ← user = 평가 컨텍스트(Env)가 바인딩한 "이 시점의 페르소나"
  {{ char.name }}   ← 표기는 시점 중립, 해석이 시점 의존

  eval : Body × Env → Text,   Env ⊇ { user, char, chat, …, viewpoint }
  같은 본체 × 다른 Env = 다른 프롬프트 — 재파싱 없음, 문법 무변경

시점 의존 "계산" — (b) 수준이 필요하고, (b)로 충분:
  {{ name_for(speaker) }}      ← "이 시점에서 그를 뭐라 부르나"
  {{ utter(speaker, text) }}   ← 그룹챗 발화자 표기 — 화자/청자도 Env의 인자
  시점은 capability가 Env에서 읽는 암묵 인자 — 문법에는 나타나지 않는다

viewpoint는 상한을 올리지 않는다. 요구 전부가 (b)에서 수용되고, (a)의 모순(승격 함수 호출 지점 부재)은 viewpoint와 무관하게 이미 성립한다. viewpoint가 실제로 제약하는 것은 상한이 아니라 참조의 평가 시점이다:

불변식 (어느 상한을 골라도) — 참조는 지연 해석이다. {{user}} 류를 파싱 시점에 문자열로 즉시 치환하면 단일 시점이 IR에 새겨져 분기 B가 구조적으로 막힌다. AST/IR은 참조를 미해석 이름으로 보존하고, 해석은 평가 시점에 Env(viewpoint·speaker·listener 포함)에 대해 일어난다. 이 불변식은 문법이 아니라 평가 의미론의 소관이다.

witness 서열 (2026-07-04 보강) — 이 불변식은 미래 가정이 아니라 현재형이다:

현재형 witness — 그룹챗 (레거시에 이미 존재하는 기능):
  매 턴 발화 캐릭터가 바뀌고 {{char}} = "지금 발화자"로 해석된다.
  같은 카드 텍스트 × N 발화자 — 지연 해석이 이미 운용 중인 의미론이고,
  파싱 시점 고정 치환으로는 그룹챗조차 재현할 수 없다.

미래형 witness — 분기 B (멀티유저):
  {{user}} = "이 시점의 참가자". 그룹챗이 char 축(발화자)에서 하던 것의
  user 축(시점) 확장 — 새 의미론이 아니라 같은 불변식의 두 번째 시점이다.

∴ witness 테스트 두 벌 (둘 다 파싱은 1회):
  ① 같은 조립 × 두 발화자 Env → {{char}} 상이   — 그룹챗, 구현 즉시 검증 가능
  ② 같은 조립 × 두 viewpoint  → {{user}} 상이   — 분기 B, 예정

부수 판별 — assembly fan-out 구문의 실수요 (그룹챗 기준): 그룹챗의 다중성은 "한 조립 × N 발화자 Env 평가"다 — 조립 그래프가 갈라지는 게 아니라 평가가 반복된다. eval : Body × Env → Text가 이미 Env를 인자로 받으므로 N 발화자는 호스트의 평가 루프(엔진 소관, 문법 밖)로 수용된다. ∴ 현존하는 유일한 다중성 실수요는 fan-out 구문을 요구하지 않는다 — residual의 fan-out 항목은 "그래프 분기(센서 하나 → 모터 둘)"로 범위가 좁혀지고, 그쪽 실수요는 아직 미관찰이다.

도출

  • (a)는 성장 규율과 모순 (도출됨 — 승격 함수의 호출 지점 부재), (c)는 규율의 압력 신호를 무디게 한다. (b)가 규율의 정의에서 직접 도출되는 유일한 상한 이다: capability 모델이 함수 호출이므로, 본체의 표현력 = 정확히 함수 호출까지. 멀티유저 분기 B의 시점 요구도 (b)에서 전부 수용된다 — 상한을 올리지 않는다 (위 절). 단 어느 상한에서도 지연 해석 불변식은 별도로 지켜야 한다.
  • caveat: 조건 분기({{ if … }})는 이 결정점에 아직 없다 — 조건은 센서(감지 부품)의 소관으로 두는 것이 현 설계인데, 본체 내 국소 조건("이름 있으면 이름, 없으면 '당신'")의 요구가 오면 별도 결정점이 된다. residual로 남긴다. 멀티유저 관찰: "다른 참가자에게만 보이는 요소"(개인 비밀·개인 로어)는 본체 내 조건이 아니라 시점 조건 센서(viewpoint를 읽는 감지 부품)로 표현 가능 — 조건을 센서 소관으로 두는 현 설계가 분기 B에서도 버틴다는 첫 증거다.

⑤ 식별자 한국어 허용

(a) ASCII만 — [a-zA-Z_][a-zA-Z0-9_]*

  • lexer 최소. 그러나 대상 유저(카드 제작자 — 한국어 커뮤니티)가 부품에 날씨감지 대신 weather_sensor를 강제당한다 — 3단계 UI에서 부품 이름이 그대로 유저 대면 레이블이 되는 순간 이 비용이 표면화된다.

(b) 유니코드 문자 — [\p{L}_][\p{L}\p{N}_]* (XID 계열)

part 날씨감지 {
  in 대화: Chat
  out 감지됨: Signal
}
  • 커스텀 lexer를 어차피 소유하므로 추가 비용 ≈ 문자 클래스 교체 + 정규화 뿐이다. LALRPOP 문법에는 아무 변화 없음 (Ident는 lexer가 만드는 토큰).
  • 키워드가 전부 ASCII이므로 (①②의 어느 안에서도) 한국어 식별자와 키워드 충돌은 원리적으로 불가능 — 오히려 예약어 밟을 걱정이 없는 유일한 식별자 공간이다.
  • 필수 부속: NFC 정규화. (U+AC01 완성형)과 ᄀ+ᅡ+ᆨ(조합 자모)은 화면이 같고 코드포인트가 다르다 — macOS 파일 경유 복붙이 NFD를 들여온다. lexer에서 식별자 토큰 생성 시 NFC로 접으면 "보이는 게 같으면 같은 식별자"가 성립한다. (unicode-normalization crate, 식별자에만 적용 — 본체 텍스트는 원문 보존.)
  • confusable(동형 이의 문자)은 남는다 — 단 mollu는 위험 구문이 부재해 이것이 보안 경계가 아니라 진단 문제이고, 오타 식별자는 어차피 조립 검증의 UnknownPart/UnknownCapability가 잡는다 (witness 테스트 존재). "비슷한 이름 제안" 진단(레벤슈타인)을 붙이면 잔여 혼란도 흡수된다.

도출

  • 이 결정점은 다섯 중 실현 비용이 가장 작고 (lexer 소유가 이미 확정이라 한계 비용이 정규화 하나), 대상 유저와의 정합 이득이 명확하다. 유일한 실질 결정은 "허용 여부"가 아니라 NFC 정규화를 식별자 동일성의 정의에 넣을 것인가다 — 넣지 않으면 (b)는 (a)보다 나쁜 함정이 된다 (보이는 게 같은데 다른 식별자).

경계 확인 — regex 리터럴과 lexer (유저 확인 사항 반영, 2026-07-04)

전제(확인됨): mollu 문법과 rust regex는 별개 층이다 — 문법 = 표기 파싱(컴파일 타임), regex = 패턴 센서 부품의 실행 능력(런타임). 접점은 regex가 표기 안에 불투명 문자열 토큰으로 등장할 수 있다는 것뿐, 문법이 regex 문법을 품지 않는다. 그 접점의 lexer 비용만 따진다:

(가) 일반 문자열로 전달 — 전용 토큰 없음
  part 빗소리감지 { kind sensor  param pattern: Pattern
                    sense match(chat.last, "비|빗소리") }
  lexer 비용 = 0 (기존 문자열 토큰 재사용)
  regex 컴파일·오류는 조립 검증 시점 (도메인 crate의 compile_pattern)

(나) 접두 문자열 리터럴 — re"…"
  sense match(chat.last, re"비|빗소리")
  lexer 비용 ≈ 0 (접두어 하나 — 새 모드 아님, 문자열 스캔 규칙 그대로)
  이득: 파서가 "이건 패턴"임을 알아 컴파일을 파싱 직후로 앞당김 → 오류가
  조립 전에, 해당 토큰 위치로 보고됨

(다) 나체 /…/ 리터럴 — 탈락
  /비|빗소리/ — 다른 언어(JS)의 관례지만, lexer가 /의 시작·끝 문맥을
  추적해야 함 (JS lexer의 고전적 난점). 이득 없이 모드성 상태만 추가.

∴ (가)와 (나)는 lexer 모드 수를 늘리지 않는다 — 실질 차이는 regex 문법 오류가 보고되는 시점(검증 시 vs 파싱 직후)뿐이고, 이는 문법 결정이 아니라 진단 파이프라인 결정이다. (다)만 구조적 비용이 있어 배제 근거가 선다. 어느 쪽이든 백트래킹·backreference 불가는 regex 층의 성질(rust regex — witness 테스트 존재)이라 문법 결정과 무관하게 유지된다.


결정점 간 의존 구조

③ (본체 경계) ──확정 시──▶ ① A/B의 구조 차이 소멸 (표기 취향만 잔존)
② (키워드 지위) ──(a) 선택──▶ ①B/혼합 전제,  (b) 선택──▶ ①A와 자연 결합
④ ⑤ — 독립 (어느 조합과도 직교)

∴ 결정 순서로는 ③ → ② → ① → ④ → ⑤가 경제적이다 — 상류가 하류의 선택지를 줄이는 방향. (④⑤는 아무 때나.)

각 결정점의 비대칭 정도 (선택지 간 격차가 큰 순):

③ — 구조적 탈락 사유 존재 ((a)(b) 모두 관찰된 본문 내용과 충돌)
④ — (a)가 성장 규율과 모순으로 도출됨, (b)/(c)는 규율 해석 차
② — branching assumption(범주 닫힘) 미검증 — 가정 확정 전엔 결정 불가
① — ③ 확정 후엔 취향 결정
⑤ — 허용 자체보다 NFC 동일성 정의가 실질 결정

residual

  • 본체 내 {{ 리터럴 탈출 표기 — ③의 어느 안에서도 공통 필요, 미설계.
  • 본체 내 국소 조건 분기의 요구 여부 — ④에 없음, 센서 소관으로 두는 현 설계가 버틸지는 초기 어휘 확정 후 판정 (분기 B의 시점 조건은 센서로 수용됨 — ④ caveat).
  • Env 바인딩 어휘user/char/chat 등 섬에서 보이는 이름의 목록과 타입이 미확정. 지연 해석 불변식의 구체 스키마 = 프롬프트 IR 설계 (1단계 첫 작업 — 위 "구현 단계" 참조)와 공동 결정 대상. speaker/listener/viewpoint가 Env의 표준 원소인지, 부품 슬롯으로 오는지도 여기 포함. 레거시 대응물 (설계 재료): 레거시 그룹챗 스키마(참가자별 발화 가중치·활성 플래그를 담은 평행 배열 + 메시지별 발화자 id 필드)가 커버 범위의 하한을 정의한다 — 참가자 집합 → participants, 발화자 id → speaker. 이름·형태는 새로 정한다.
  • 부품 범주의 닫힘 여부 — ②의 branching assumption, 초기 어휘(위 "초기 어휘 후보") 확정과 동시에 판정 가능.
  • assembly 구문의 그래프 분기(센서 하나 → 모터 둘) — 이번 5개 결정점 밖. 단 범위가 좁혀짐 (④의 부수 판별): 그룹챗의 N-발화자 다중성은 "한 조립 × N Env 평가 루프"(엔진 소관)로 수용되어 구문 실수요가 아님이 판별됐고, 남는 건 그래프 분기뿐인데 그 실수요는 미관찰 — 실수요 관찰 전까지 문법 추가 보류가 규율(작은 문법) 정합.
  • 펜스 문자 선택 (""" vs 백틱 vs ---) — ③(c) 채택 시의 하위 결정.