/* * 연결 적합성 하네스 — **통합을 시험 스위트처럼 돌린다.** * * 선언 적합성 하네스 세 벌과 같은 규율을 **연결**에 적용한다: 사실이 오류 없이 사라지는 것을 막는다. * 다른 점은 대상이 우리 선언이 아니라 **남의 시스템**이라는 것이고, 그래서 두 규칙이 더 붙는다. * * ① **돌리지 않은 프로브는 「실패」가 아니라 「모른다」다.** 실패로 처리하면 사람이 그 빨간 칸을 * 없애려고 무엇이든 하게 되고, 모른다로 처리하면 **무엇을 아직 모르는지**가 남는다. 판정 * (`assessStagePath`)이 「모른다」를 달성으로 바꾸지 않으므로 이 구분이 끝까지 살아 있다. * ② **쓰기 프로브는 명시 동의 없이 돌지 않는다.** 이 스위트는 고객의 운영 시스템을 두드린다. * 읽기는 기본으로 돌리고, 남의 공장에 명령을 넣는 프로브는 사람이 켜야 돈다 — 켜지 않으면 * 폐루프 칸은 「검증되지 않음」으로 남는다(그게 사실이다). * * 순수 모듈이다(전송·DB 무의존). 실행기는 이 선언을 읽어 프로브를 돌리고 `ProbeResult[]` 를 낸다. * 화면은 그 결과와 `assessStagePath` 의 판정을 함께 보인다 — 초록/빨강과 **그 초록이 무엇을 증명하나**. */ import type { StageEvidence, StageId } from './stage-path.ts' export type ProbeId = | 'connect' // 붙는다 | 'discover' // 사이트가 열거된다 | 'master' // 구조가 들어온다 | 'master-warnings' // 못 옮긴 값을 세었다 | 'items' // 자리에 물품이 놓인다(비직렬 수량 포함) | 'live-ingest' // 이벤트가 들어온다 | 'live-removal' // **반출이 관측된다** — 진짜 delta 의 증거 | 'declarations' // 공정 선언이 실려 있다 | 'forecast' // 예측이 돈다 | 'actuate-declared' // 액추에이션 능력이 선언∩구현이다 | 'actuate-effect' // 명령의 효과가 상관되어 돌아온다 (쓰기) export interface ProbeSpec { id: ProbeId /** 이 프로브가 증거를 대는 단계. */ stage: StageId /** **무엇을 증명하나** — 언어중립 키(사람 말은 표현계층이 렌더). 「무엇을 호출하나」가 아니다. */ proves: string /** 대상 시스템을 바꾸는가. 참이면 사람이 켜야 돈다. */ writes: boolean /** 이 프로브가 실패하면 뒤 프로브를 돌릴 의미가 없다. */ gating: boolean } /** * 시퀀스 — 순서가 곧 방법론이다. 앞이 막히면 뒤는 돌리지 않는다(돌려도 답이 무의미하다). * * `master-warnings` 를 **별개 프로브**로 둔 이유: 구조가 들어오는 것과 못 옮긴 값을 세는 것은 다른 * 사실이고, 세지 않은 인제스트는 도크를 랙으로 바꿔 놓고도 성공으로 보인다. */ export const PROBE_SEQUENCE: readonly ProbeSpec[] = [ { id: 'connect', stage: 'structure', proves: 'probe.connect', writes: false, gating: true }, { id: 'discover', stage: 'structure', proves: 'probe.discover', writes: false, gating: true }, { id: 'master', stage: 'structure', proves: 'probe.master', writes: false, gating: true }, { id: 'master-warnings', stage: 'structure', proves: 'probe.masterWarnings', writes: false, gating: false }, { id: 'items', stage: 'structure', proves: 'probe.items', writes: false, gating: false }, { id: 'live-ingest', stage: 'live', proves: 'probe.liveIngest', writes: false, gating: true }, { id: 'live-removal', stage: 'live', proves: 'probe.liveRemoval', writes: false, gating: false }, { id: 'declarations', stage: 'forecast', proves: 'probe.declarations', writes: false, gating: false }, { id: 'forecast', stage: 'forecast', proves: 'probe.forecast', writes: false, gating: false }, { id: 'actuate-declared', stage: 'closed-loop', proves: 'probe.actuateDeclared', writes: false, gating: true }, { id: 'actuate-effect', stage: 'closed-loop', proves: 'probe.actuateEffect', writes: true, gating: false } ] /** 프로브 한 건의 결과. `ran: false` = 돌리지 않았다(실패가 아니다). */ export interface ProbeResult { id: ProbeId ran: boolean /** * 이 대상에 **해당하지 않는다**. 통과도 실패도 미측정도 아닌 네 번째 값이다. * * 시뮬레이션 트윈에 라이브 프로브를 들이대면 빨간 칸이 생기는데 그건 결함이 아니다 — 시뮬은 원천에서 * 받는 것이 없다. 레퍼런스 없이 템플릿으로 세운 트윈에는 옮겨 온 마스터가 없으므로 「못 옮긴 값」도 없다. * 이걸 통과로 처리하면 없는 능력을 주장하고, 미측정으로 처리하면 영원히 배선을 기다린다. */ applicable?: boolean /** 돌렸을 때의 판정. 돌리지 않았으면 무의미하다. */ passed?: boolean /** 센 값(자리 수·반출 건수·선언 수 등). 세지 않았으면 없다. */ count?: number /** 선언 적재량 프로브만 — `declared`/`total`. */ coverage?: { declared: number; total: number } /** 왜 실패했나·왜 건너뛰었나. 언어중립 코드 또는 원문 오류. */ detail?: string } const by = (rs: readonly ProbeResult[], id: ProbeId) => rs.find(r => r.id === id) /** 돌렸고 셌으면 그 수, 아니면 `null`(모른다). 실패한 프로브의 수를 0 으로 읽지 않는다. */ /** * 센 값은 **판정과 무관한 사실**이다 — `passed` 를 요구하면 「세어서 0 을 얻었다」가 「모른다」와 구별되지 않는다. * 그 구분이 이 하네스의 전부이므로 여기서 잃으면 나머지가 무의미하다. 해당 없음만 `null` 이다. */ const counted = (r: ProbeResult | undefined): number | null => r?.applicable === false ? null : r?.ran && r.count !== undefined ? r.count : null /** 돌렸고 셌으면 그 수, 아니면 0. **존재 여부**를 묻는 자리에만 쓴다(모름과 0 을 구별할 필요가 없는 축). */ const countedOrZero = (r: ProbeResult | undefined): number => counted(r) ?? 0 /** * 프로브 결과를 **증거**로 처리한다. * * 계산 규칙의 정수: **돌리지 않은 프로브는 `null`** 로 간다. 그러면 판정이 그 단계를 달성으로 * 만들지 않고 `unmeasured` 로 말한다 — 스위트를 절반만 돌리고 초록을 주장할 길이 없다. */ export function foldProbeResults(results: readonly ProbeResult[]): StageEvidence { return { locationCount: countedOrZero(by(results, 'master')), itemCount: countedOrZero(by(results, 'items')), masterWarnings: counted(by(results, 'master-warnings')), /* 해당 없음은 **미측정이 아니다** — 물을 대상이 아닌 축을 판정에서 뺀다. */ masterWarningsApplicable: by(results, 'master-warnings')?.applicable !== false, liveIngestTotal: countedOrZero(by(results, 'live-ingest')), removalsObserved: counted(by(results, 'live-removal')), forecastRan: by(results, 'forecast')?.ran === true && by(results, 'forecast')?.passed === true, specCoverage: (() => { const r = by(results, 'declarations') return r?.ran && r.passed && r.coverage ? r.coverage : null })(), actuateCapability: by(results, 'actuate-declared')?.passed === true, correlatedEffectObserved: counted(by(results, 'actuate-effect')) } } /** * 지금 돌릴 프로브 — 게이팅과 쓰기 동의를 지킨다. * * 앞의 게이팅 프로브가 실패했으면 **거기서 멈춘다.** 뒤를 돌려 빨간 칸을 늘리는 것은 정보를 주지 * 않는다(원인은 이미 앞에 있다). 쓰기 프로브는 `allowWrites` 없이는 목록에 들어오지 않는다. */ export function plannedProbes(results: readonly ProbeResult[], opts: { allowWrites?: boolean } = {}): ProbeId[] { const out: ProbeId[] = [] for (const spec of PROBE_SEQUENCE) { if (spec.writes && !opts.allowWrites) continue const prior = by(results, spec.id) if (prior?.applicable === false) continue // 해당 없는 프로브는 계획하지 않는다 if (!prior?.ran) out.push(spec.id) if (spec.gating && prior?.ran && prior.passed === false) break } return out } /** 해당 없음으로 판정된 프로브 — 화면이 「왜 회색인가」를 말할 수 있게 사유와 함께 낸다. */ export function notApplicable(results: readonly ProbeResult[]): { id: ProbeId; detail?: string }[] { return results.filter(r => r.applicable === false).map(r => ({ id: r.id, ...(r.detail ? { detail: r.detail } : {}) })) }