/* * 노브 기본값 적용 — **순수**. "안 준 값은 템플릿이 선언한 기본값으로." * * ── 왜 있나 (2026-08-05) ──────────────────────────────────────────────────── * 템플릿은 노브마다 기본값을 선언한다(`knobs[].default`). 그런데 그 기본값을 채우는 일을 **위자드만** * 하고 있었다 — 서버는 받은 파라미터를 그대로 `build(p)` 에 넘긴다. 그래서 파라미터를 일부만 주고 * 부르면(AI·스크립트·API 호출자) 템플릿 안에서 `Number(p.inboundRate) || 0` 이 **0** 이 되고, * 발생률 0 = 도착 없음 = **아무것도 하지 않는 트윈**이 알리지 않고 만들어진다. * * 실제로 그 함정을 밟았다: 유통가공 창고를 파라미터 일부만 주고 만들었더니 이벤트가 0건이었고, * 처음에는 템플릿이 깨진 것으로 오진했다. 기본값을 아는 곳이 계약(노브 스키마)인데 그것을 적용하는 * 곳이 화면뿐이면, 화면을 거치지 않는 모든 경로가 함정이 된다. * * ── 0 을 기본값으로 덮지 않는다 ───────────────────────────────────────────── * 발생률 `0` 은 "도착 없음" 이라는 **뜻 있는 값**이다. 없는 것과 0 은 다르다 — falsy 로 판정해 덮으면 * "자극 없이 구조만 보겠다" 는 선언이 알리지 않고 뒤집힌다. 그래서 **키가 없을 때만** 채운다. * `null` 은 없는 것으로 본다(JSON·GraphQL 경로에서 "주지 않음" 이 null 로 오는 일이 흔하다). * * ── 선언 밖 파라미터는 손대지 않는다 ──────────────────────────────────────── * 템플릿은 노브에 없는 값도 읽는다(예: `geo` 대신 `lat`/`lon` 폴백). 모르는 키를 걷어내면 그 경로가 * 알리지 않고 끊긴다. 그대로 통과시킨다. */ /** 템플릿 노브 — 기본값 적용에 필요한 만큼만 본다. */ export interface KnobLike { key?: string default?: unknown } /** * 빠진 노브를 기본값으로 채운다. * * @param knobs 템플릿이 선언한 노브들(없으면 파라미터를 그대로 돌려준다). * @param params 호출자가 준 파라미터. * @returns 새 객체 — 입력을 바꾸지 않는다(호출자의 객체가 몰래 변하면 추적이 어렵다). */ export function applyKnobDefaults(knobs: readonly KnobLike[] | undefined | null, params: Record | undefined | null): Record { const out: Record = { ...(params ?? {}) } for (const k of knobs ?? []) { if (!k?.key) continue if (k.default === undefined) continue // 기본값을 선언하지 않은 노브는 채울 것이 없다 const given = out[k.key] if (given === undefined || given === null) out[k.key] = k.default } return out } /** * 기본값으로 채운 노브 이름들 — 호출자에게 "무엇을 내가 정해 줬는지" 알릴 때 쓴다. * * 알리지 않고 채우는 것도 알리지 않고 비우는 것만큼 위험하다: 사용자가 준 적 없는 값으로 트윈이 만들어졌는데 * 아무 말이 없으면, 나중에 "왜 이 수치인가" 를 아무도 답할 수 없다. */ export function filledKnobKeys(knobs: readonly KnobLike[] | undefined | null, params: Record | undefined | null): string[] { const given = params ?? {} return (knobs ?? []) .filter(k => k?.key && k.default !== undefined && (given[k.key!] === undefined || given[k.key!] === null)) .map(k => k.key!) }