/* * **관측에서 조치를 낸다** — 그 판정 하나. * * ── 무엇이 없었나 (2026-09-01) ─────────────────────────────────────────── * 승인 게이트를 세우고 나니 **낼 수 있는 것이 사람뿐**이었다. 계약은 낸 쪽을 네 가지로 두는데 * (`operator` · `rule` · `ai` · `schedule`) 사람 말고는 길이 없었다. 게이트의 값어치는 트윈이 스스로 * 알아챈 것을 사람이 판단하는 데 있는데, 알아챈 것을 올릴 자리가 없었다. * * ── 조건을 새로 만들지 않는다 ──────────────────────────────────────────── * 커널이 이미 상태에서 「봐야 할 것」을 판단해 `Attention` 으로 낸다 — 안정된 종류 코드(`kind`), * 무엇에 대한 것인지(`anchor`), 언제부터인지(`timeStamp`), 표현용 값(`params`). 조건 언어를 새로 만들면 * 같은 판단이 두 벌이 되고, 한쪽만 고쳐지는 날이 온다. * * 그래서 규칙은 **어느 주의에 어느 지시를 붙이는가**만 정한다. * * ── 자극과 지시를 잇지 않는다 ──────────────────────────────────────────── * 주의에는 `suggestedAction.command` 가 실려 있지만 그것은 **커널 안에서 도는 자극**이다 * (`resource.repair` = 모델에서 수리된 것으로 둔다). 여기서 내는 것은 현장에 나가는 지시다. 그 값을 * 그대로 쓰면 「모델을 그렇게 두라」가 「사람을 보내라」가 된다(§`propose` 의 거절). * * ── 무너지는 것 넷 ─────────────────────────────────────────────────────── * 행복한 길은 짧다. 실제로 무너지는 것은 이것들이다. * * 같은 조건이 매 주기 다시 올라온다 대기 줄에 같은 지시가 수십 개 선다 * 방금 생긴 조건에 바로 올린다 깜빡인 것에 현장 지시가 나간다 * 조건이 사라진 뒤에도 대기에 남는다 승인하는 사람이 없는 문제를 판단한다 * 한 번에 수백 개가 올라온다 사람이 읽을 수 없고, 읽지 않으면 게이트가 시늉이 된다 * * 넷 다 오류가 나지 않는다. 그래서 이 파일이 넷을 다룬다. */ import type { CommandTypeSpec } from '@operato/ops-contract' /** 어느 주의에 어느 지시를 붙이는가 — 선언 한 줄. */ export interface ActuationRuleSpec { id: string /** 이 트윈에만 적용된다. 트윈마다 라인이 다르므로 전역 규칙은 두지 않는다. */ instanceId: string /** 어느 주의인가 — 커널의 안정 코드(`Attention.kind`). */ attentionKind: string /** 이 심각도 이상일 때만 — 없으면 종류만 본다. */ minSeverity?: 'low' | 'medium' | 'high' | 'critical' /** * **조건이 이만큼 이어졌을 때** 올린다. * * 없으면 처음 본 주기에 바로 올린다. 깜빡이는 조건에 현장 지시가 나가는 것을 막으려면 이 값이 필요하다 * — 커널이 조건이 처음 성립한 시각을 기억하므로(`Attention.timeStamp`) 여기서 잴 수 있다. */ minHeldMs?: number /** 무엇을 내는가 — 선언된 조치 종류(`CommandTypeSpec.type`). 커널 자극이 아니다. */ commandType: string /** * 지시의 내용 — `{이름}` 을 주의의 값으로 바꿔 넣는다. * * 바꿀 값은 `Attention.params` 와 `anchor` 에서 온다(`{locationId}` · `{ratioPct}`). 한 겹 중괄호를 * 쓰는 것은 이 저장소의 i18n 관례와 같다. * * 바꿀 것이 없으면 **그 자리를 비운다** — 지어낸 값을 넣지 않는다. 요구하는 칸이 비면 계약이 거절하고 * (§`missingCommandPayload`) 그 거절이 사람에게 전해진다. */ payloadTemplate?: Record /** * **한 번 낸 근거에 다시 내기까지 기다리는 시간.** * * 이것이 없으면 승인하는 순간 규칙이 같은 것을 또 낸다 — 실제로 그랬다. 「결정을 기다리는 것」만 보고 * 중복을 막았는데, 승인하면 그 커맨드가 대기에서 빠지고 조건은 그대로 서 있기 때문이다. 사람이 * 승인할수록 대기 줄이 다시 차는 모양이 된다. * * 조건은 이어지는 것이고 조치는 시간이 걸린다. 낸 조치가 효과를 낼 때까지 기다리는 시간이 이 값이다. */ cooldownMs?: number /** 꺼 둔 규칙은 판정하지 않는다. 지우지 않고 끌 수 있어야 한다. */ enabled?: boolean } /** 판정에 들어가는 주의 — 커널이 낸 모양 그대로 중 필요한 것만. */ export interface AttentionLike { id: string kind: string severity: string state?: string anchor?: { locationId?: string; moverId?: string; orderId?: string; operation?: string } timeStamp?: string params?: Record } /** 올릴 것 하나. */ export interface RuleProposal { ruleId: string attentionId: string attentionKind: string commandType: string payload: Record } /** 올리지 않은 것과 그 이유 — **말하지 않으면 사람은 규칙이 도는 줄 안다.** */ export interface RuleSkip { ruleId: string attentionId?: string reason: string } export interface RuleVerdict { proposals: RuleProposal[] /** 왜 안 올렸나. 조용히 넘어가지 않는다. */ skips: RuleSkip[] /** 상한에 닿아 잘랐나 — 잘랐으면 몇 개를 두고 왔나. */ withheld?: number } const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'] as const /** 심각도가 문턱에 닿나 — 모르는 값은 닿지 않는 것으로 본다(모름을 통과로 읽지 않는다). */ function severityMeets(actual: string, min: string | undefined): boolean { if (!min) return true const a = SEVERITY_ORDER.indexOf(actual as any) const m = SEVERITY_ORDER.indexOf(min as any) if (a < 0 || m < 0) return false return a >= m } /** * `{이름}` 을 바꿔 넣는다 — 바꿀 값이 없으면 **그 칸을 뺀다.** * * 원래 문자열을 그대로 두면 `{quantity}` 같은 글자가 지시의 수량으로 들어가고, 그 지시가 현장에 나간다. * 빼면 계약이 「요구하는 칸이 없다」로 거절하고 그 거절이 사람에게 전해진다. */ function fill(template: Record | undefined, values: Record): { payload: Record missing: string[] } { const payload: Record = {} const missing: string[] = [] if (!template) return { payload, missing } for (const [key, raw] of Object.entries(template)) { if (typeof raw !== 'string') { payload[key] = raw continue } /* 값 하나를 그대로 나르는 자리 — 수를 문자열로 만들지 않는다(수량이 문자열이면 계산이 무너진다). */ const whole = /^\{([A-Za-z0-9_.]+)\}$/.exec(raw) if (whole) { const name = whole[1]! if (!(name in values)) { missing.push(name) continue } payload[key] = values[name] continue } let gap = false const text = raw.replace(/\{([A-Za-z0-9_.]+)\}/g, (_m, name: string) => { if (name in values) return String(values[name]) missing.push(name) gap = true return '' }) if (!gap) payload[key] = text } return { payload, missing } } /** 바꿔 넣을 값 — 주의의 `params` 와 `anchor` 에서. */ function valuesOf(attention: AttentionLike): Record { const out: Record = { ...(attention.params ?? {}) } for (const [key, value] of Object.entries(attention.anchor ?? {})) { if (value !== undefined && value !== null && value !== '') out[key] = value as string } out.attentionId = attention.id out.attentionKind = attention.kind return out } /** * 이번에 무엇을 올릴지 정한다 — **순수 함수**. 저장소도 시계도 보지 않는다. * * 시험이 재기동과 상한과 중복을 다 걸을 수 있어야 하고, 그러려면 이 판정이 밖에서 값을 받아야 한다. * 이 저장소에서 가장 자주 나는 결함이 「자리는 있고 길이 없다」이고, 게이트에 물리는 것이므로 여기서 * 나면 결과가 현장의 지시다. */ export function evaluateRules(args: { rules: readonly ActuationRuleSpec[] attentions: readonly AttentionLike[] /** 선언된 조치 종류 — 없는 종류를 가리키는 규칙은 여기서 걸린다. */ specs: readonly CommandTypeSpec[] /** * **이미 다루고 있는 근거들** — 근거마다 마지막으로 조치를 낸 시각. * * 결정을 기다리는 것만 보면 부족하다. 승인하는 순간 그 커맨드가 대기에서 빠지고 조건은 그대로 * 서 있어서, 다음 바퀴가 같은 것을 또 낸다 — 걸어 보고 그 상태를 실제로 만들었다. * * 그래서 **낸 시각**을 받는다. 결정을 기다리는 것은 시각과 무관하게 막는다(§`openCauses`). * 저장소에서 읽어 넘긴다 — 프로세스 기억에 두면 재기동이 곧 중복이 된다. */ openCauses: ReadonlySet /** 근거 → 마지막으로 조치를 낸 시각(ms). 없으면 낸 적이 없다. */ lastProposedAt?: ReadonlyMap /** 지금 — 조건이 얼마나 이어졌는지 재는 기준. */ nowMs: number /** 한 번에 몇 개까지 올리나. 넘는 것은 두고 오고, 두고 온 수를 말한다. */ maxPerRun?: number }): RuleVerdict { const { rules, attentions, specs, openCauses, nowMs } = args const maxPerRun = args.maxPerRun ?? DEFAULT_MAX_PER_RUN const proposals: RuleProposal[] = [] const skips: RuleSkip[] = [] let withheld = 0 /* 한 주의에 규칙이 둘 붙어 있어도 조치는 하나만 올린다 — 같은 조건에 두 지시가 나가면 현장이 두 번 움직인다. */ const claimed = new Set(openCauses) for (const rule of rules) { if (rule.enabled === false) continue const gap = commandTypeGap(rule.commandType, specs) if (gap) { skips.push({ ruleId: rule.id, reason: gap }) continue } const spec = specs.find(s => s.type === rule.commandType)! for (const attention of attentions) { if (attention.kind !== rule.attentionKind) continue /* 확인된 것과 사라진 것에는 올리지 않는다 — 사람이 이미 본 것이다. */ if (attention.state && attention.state !== 'active') continue if (!severityMeets(attention.severity, rule.minSeverity)) continue if (claimed.has(attention.id)) continue /* * 낸 지 얼마 안 됐으면 기다린다. 조건은 이어지는 것이고 조치는 시간이 걸린다 — 효과를 보기 전에 * 또 내면 대기 줄이 같은 지시로 찬다. */ const lastAt = args.lastProposedAt?.get(attention.id) const cooldown = rule.cooldownMs ?? DEFAULT_COOLDOWN_MS if (lastAt !== undefined && cooldown > 0 && nowMs - lastAt < cooldown) continue const held = heldMs(attention, nowMs) if (rule.minHeldMs && rule.minHeldMs > 0) { if (held === undefined) { /* * 언제부터인지 모르는 주의다. **모름을 「충분히 오래됐다」로 읽지 않는다** — 그렇게 읽으면 * 방금 생긴 깜빡임에 현장 지시가 나간다. */ skips.push({ ruleId: rule.id, attentionId: attention.id, reason: `이 주의는 언제부터인지 말하지 않는다 — ${rule.minHeldMs}ms 이어졌는지 알 수 없어 올리지 않았다` }) continue } if (held < rule.minHeldMs) continue } if (proposals.length >= maxPerRun) { withheld++ continue } const { payload, missing } = fill(rule.payloadTemplate, valuesOf(attention)) if (missing.length) { skips.push({ ruleId: rule.id, attentionId: attention.id, reason: `지시에 넣을 값이 주의에 없다 (${[...new Set(missing)].join(', ')}) — 지어낸 값으로 현장 지시를 만들지 않았다` }) continue } const gaps = missingRequired(spec, payload) if (gaps.length) { skips.push({ ruleId: rule.id, attentionId: attention.id, reason: `지시가 요구하는 칸이 비었다 (${gaps.join(', ')}) — 무엇을 지시하는지 모르면 승인할 근거가 없다` }) continue } claimed.add(attention.id) proposals.push({ ruleId: rule.id, attentionId: attention.id, attentionKind: attention.kind, commandType: rule.commandType, payload }) } } return { proposals, skips, ...(withheld ? { withheld } : {}) } } /** 한 번에 올리는 상한 — 사람이 읽을 수 있는 수. 읽지 않는 대기 줄은 게이트가 아니다. */ export const DEFAULT_MAX_PER_RUN = 20 /** * 같은 근거에 다시 내기까지 — **선언하지 않아도 기다린다.** * * 기본을 0 으로 두면 선언하지 않은 규칙이 승인할 때마다 같은 것을 또 낸다. 그 결함을 기본값으로 두지 * 않는다. 더 빨리 내야 하는 규칙은 스스로 짧게 적는다. */ export const DEFAULT_COOLDOWN_MS = 60 * 60_000 /** * 이 규칙이 낼 수 있는 종류인가 — **판정이 한 자리에만 있게.** * * 저장할 때와 돌 때가 같은 것을 봐야 한다. 두 벌이면 저장은 되는데 안 도는 규칙이 생기고, 사람은 규칙이 * 걸린 줄로 읽는다. * * `stimuli` 를 받는 이유: 커널 자극(`CMD`)을 적었을 때 **왜 안 되는지**를 말하려면 그 어휘를 알아야 한다. * 「선언되지 않았다」로만 답하면 부르는 쪽은 선언이 빠진 줄로 읽고 선언을 추가한다 — 그 순간 모델 안의 * 자극이 현장 지시가 된다. */ export function commandTypeGap( commandType: string, specs: readonly CommandTypeSpec[], stimuli?: readonly string[] ): string | undefined { if (specs.some(s => s.type === commandType)) return undefined if (stimuli?.includes(commandType)) { return ( `그것은 커널 안에서 도는 자극이다 (${commandType}) — 현장에 나가는 지시가 아니다. ` + '모델 상태를 바꾸려면 커널에 자극을 넣고, 현장에 무언가를 시키려면 그 지시를 조치 종류로 선언한다' ) } return `선언되지 않은 조치 종류를 가리킨다 (${commandType}) — 선언된 것: ${specs.map(s => s.type).join(', ') || '(없음)'}` } /** 조건이 얼마나 이어졌나 — 모르면 `undefined`(0 이 아니다). */ function heldMs(attention: AttentionLike, nowMs: number): number | undefined { if (!attention.timeStamp) return undefined const at = Date.parse(attention.timeStamp) if (!Number.isFinite(at)) return undefined return Math.max(0, nowMs - at) } /** 계약의 요구를 그대로 본다 — 판정을 여기서 다시 쓰지 않는다. */ function missingRequired(spec: CommandTypeSpec, payload: Record): string[] { return (spec.required ?? []).filter(key => { const v = payload[key] return v === undefined || v === null || v === '' }) }