/* * ISA-95 적합성 기준 — **표준이 요구하는 것의 목록.** * * ── 왜 이 파일이 필요한가 ────────────────────────────────────────────────────── * 지금까지 화면은 "우리가 가진 축" 만 보였다. 그러면 채운 것은 세어지지만 **빠진 것은 셀 수 없다** — * 없는 것은 화면에 없으므로 아무도 그것을 묻지 않는다. 표준 준수는 "우리 축에 표준 이름을 붙였다" * 가 아니라 **표준의 목록을 기준으로 빠진 것이 없다**는 말이어야 한다. 기준이 코드로 있어야 그 * 문장을 검사할 수 있다. * * ── 세 층에서 본다 ──────────────────────────────────────────────────────────── * 표준 준수는 한 층의 일이 아니다. 개념마다 셋을 따로 본다: * * · `structure` — **데이터구조**가 있나(커널 축·호스트 표에 이 개념의 자리가 있나) * · `behavior` — **기능**이 그것을 쓰나(커널이 계획·집행·집계에서 실제로 다루나) * · `surface` — **UI 노출**이 있나(사용자가 그것을 볼 수 있나) * * 셋을 합치면 "구조는 있는데 아무도 안 쓰는 컬럼" 과 "커널은 쓰는데 화면에 없는 사실" 이 같은 * 값으로 보인다. 실제로 이 프로젝트에서 둘 다 나왔다(인력 명세는 구조·기능이 있었지만 화면에 * 없었고, `properties` 는 구조만 있다). * * ── 값은 관측이 아니라 선언이다 ──────────────────────────────────────────────── * 여기 적힌 것은 **사람이 판단해 적은 사실**이다(자동 판정이 아니다). 그래서 틀릴 수 있고, 틀리면 * 여기를 고쳐야 한다 — 화면은 이 선언을 그대로 보이고 지어내지 않는다. 자동으로 알 수 있는 것 * 하나는 자동으로 한다: `axis` 가 커널 축 목록에 없으면 그것은 **구조가 없는 것**이다(오타 방지). * * ── 이 표가 커널에 없는 이유 ────────────────────────────────────────────────── * 커널은 **런타임 모델의 SSOT** 다(무엇이 있는가). 이 표는 **참조 모델의 목록**이다(무엇이 있어야 * 하는가). 둘은 다른 종류의 사실이고, 후자는 커널을 배포하지 않고도 늘어야 한다. */ /** 한 층의 상태 — 있음 · 부분 · 없음. "부분" 을 두는 이유: 절반 있는 것을 있다고 하면 기준이 죽는다. */ export type CoverageLevel = 'full' | 'partial' | 'none' export interface Isa95Concept { /** 표준이 부르는 이름 — 이 표의 키다(우리 어휘가 아니라 표준 어휘). */ std: string /** 표준의 어느 부분인가 — Part 2(자원 모델) · Part 4(일정·실적) 등. */ part: string /** 사람이 읽는 이름(i18n 키). 화면이 번역한다. */ label: string /** 우리 커널 축 — 있으면 데이터구조가 있는 것이다. 없으면 `null`. */ axis: string | null /** 세 층의 상태. `structure` 는 `axis` 로도 검증된다. */ structure: CoverageLevel behavior: CoverageLevel surface: CoverageLevel /** 왜 그 수준인지 — **모르는 것을 감추지 않기 위해** 한 줄로 적는다(i18n 키). */ note?: string /** * 자기 축이 없을 때 **어디서 보이나** — 그 사실을 들고 있는 축들. * * 「어디에」칸이 두 가지를 합쳐고 있었다: *아직 축이 없다(부채)* 와 *축일 수 없다(설계)*. 가용 능력은 * 개체가 아니라 자원의 지금 상태이므로 축이 될 수 없는데, 세 층이 다 완전한 줄이 "개념 축이 없습니다" * 라고만 말하면 **표가 스스로 모순된 말을 한다**(다 됐다면서 없다고 한다). * * 그래서 축일 수 없는 것은 갈 곳을 가리킨다. 여기가 비어 있고 축도 없으면 그것은 **진짜 빈칸**이다. * * ── ★ 뜻을 고쳐 적는다 (2026-08-24, 사용자 지적) ────────────────────────── * 한때 이 칸을 「**어디서 보이나**」로 읽고, 화면이 그것을 「해당 축 항목에 표시됩니다」라는 문장으로 * 냈다. 두 가지가 잘못이었다. * * ① 그 문장은 **한국어가 아니었다**(사용자 지적). 그리고 칩이 이미 갈 곳을 보이므로 **군더더기**다. * ② 뜻이 틀렸다. 이 칸은 「**어디에 속하나**」다 — 그 개념이 자기 목록을 갖지 않고 **어느 축의 안에 * 들어 있나**. 화면에 그려지는지는 `surface` 가 따로 답한다. 둘은 다른 물음이다. * * 그래서 **`surface` 와 짝을 맞추지 않는다.** 자기 축이 없으면 언제나 갈 곳을 적는다 — 아직 그려지지 * 않는 개념도 **어디에 속하는지는 사실**이고, 그것을 비우면 「어디에도 없는 것」으로 읽힌다. * * 여기가 비어 있고 축도 없으면 그것은 **진짜 빈칸**이다(위 규칙). */ shownOn?: string[] } /* * 가용 능력(2026-08-12, 커널 0.6.8) — **판정과 이유가 상태에 실려 온다**(`capability {available, reason}`). * * 셋 다 완전인 근거: 상태가 판정을 들고 오고(시뮬·미러가 같은 함수로 파생한다), 배정이 같은 판정을 * 쓰고, 두 화면이 같은 낱말로 그 이유를 보인다(자원 탭 배지 · 인스펙션 관측 열). * * 왜 축이 없는가 — 가용 능력은 개체가 아니라 **자원의 지금 상태**다. 개념 카드로 세우면 사람·설비와 * 나란히 놓여 "능력이 몇 개인가" 를 세게 되는데, 그건 물을 일이 아니다. */ /* * 생산 능력(2026-08-13, 커널 0.6.12) — **구간의 양을 표준 종류로 구분한다.** * * `structure` 완전: 계약이 보고서를 낸다(`operationsCapabilityOf`) — 구간(StartTime·EndTime) · 표준 * 열거값(Committed·Available·Unattainable) · 공정별 분해 · 제약하는 축. 낱말은 B2MML 대조 후 표준 것을 * 그대로 쓴다(우리 낱말을 만들지 않았다). * `behavior` 완전: 약정은 오더에서, 달성불가는 자원 판정(`capabilityOf`)에서 온다 — 그리고 그 판정은 * 배정이 쓰는 것과 같은 한 벌이다. 표준의 `Unattainable` 정의가 그 판정과 겹친다. * `surface` 완전: 두 자리에서 보인다 — 성과 화면이 현장 전체의 "앞으로 더 받을 수 있는 몫" 을, 공정 * 상세가 그 공정 하나의 분해(상한·약정·달성불가·가용)와 제약하는 축을 보인다. 두 화면은 **같은 낱말**을 * 쓴다(같은 i18n 키) — 같은 사실을 다르게 부르면 어느 쪽이 맞는지 아무도 모른다. * * 남은 것 — 표준은 **게시된(약속된) 능력**도 담는다(계산이 아니라 선언). 값을 실을 소스가 아직 * 침묵하므로 빈 축을 먼저 만들지 않는다(상태 쪽 `workCalendar` 를 지운 이유와 같다). * * 과거 분해(Used·Unused)는 이 보고서에 없다 — 그것은 실적이고 이미 두 곳에 있다(설비 OEE 의 가동/유휴, * 성과 화면의 구간 가동률). 표준이 그 둘을 다른 종류로 부르므로 **합치지 않는다.** */ /* * Part 2 — 자원 모델. 네 자원 각각이 **등급/정의 ↔ 개체** 이중구조를 갖는 것이 이 부분의 핵심이고, * 그 이중성이 이 트윈의 뼈대이기도 하다. */ const PART2: Isa95Concept[] = [ { std: 'PersonnelClass', part: '2', label: 'twin.isa95.PersonnelClass', axis: 'personnelClasses', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'Person', part: '2', label: 'twin.isa95.Person', axis: 'persons', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'PersonnelCapability', part: '2', label: 'twin.isa95.PersonnelCapability', axis: null, shownOn: ['persons'], structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.capability' }, { std: 'EquipmentClass', part: '2', label: 'twin.isa95.EquipmentClass', axis: 'equipmentClasses', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'Equipment', part: '2', label: 'twin.isa95.Equipment', axis: 'equipment', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'EquipmentCapability', part: '2', label: 'twin.isa95.EquipmentCapability', axis: null, shownOn: ['equipment'], structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.capability' }, { std: 'PhysicalAssetClass', part: '2', label: 'twin.isa95.PhysicalAssetClass', axis: 'assetClasses', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'PhysicalAsset', part: '2', label: 'twin.isa95.PhysicalAsset', axis: 'assets', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'MaterialClass', part: '2', label: 'twin.isa95.MaterialClass', axis: 'materialClasses', structure: 'full', behavior: 'partial', surface: 'full' }, { std: 'MaterialDefinition', part: '2', label: 'twin.isa95.MaterialDefinition', axis: 'materialDefinitions', structure: 'full', behavior: 'full', surface: 'full' }, /* * 로트·서브로트는 개체 축이 아니라 **관측**으로 존재한다(EPCIS 개체) — 구조는 저널이 갖는다. * * ── 2026-08-24 로트가 시험 결과를 들게 됐다 (커널 0.7.57) ───────────────── * `ItemState.testResults` 로 「이 로트를 쓸 수 있나」의 **근거**가 로트에 실린다. 그전에는 판정 * (`disposition`)만 있었고 「왜 그렇게 판정했나」를 되짚을 수 없었다. * * **구조를 `full` 로 올리지 않는다.** `MaterialLotType` 원문(B2MML-Material.xsd)이 드는 것 중 * 아직 없는 것이 있다: `MaterialLotProperty`(시험이 아닌 로트 속성 — 원산지·포장 형태) · * `TestSpecificationID`(로트별 기준 참조 — 품목 정의가 대신 답하므로 두지 않았다) · `Status` · * `HierarchyScope` · `Version`. 채운 것만 세는 것이 이 표의 규율이다. */ /* * ── 2026-08-25 — 목록이 쌓이기만 하던 것을 고쳤다 ────────────────────────── * 연결된 시스템은 매 주기 현재 재고를 전부 보내는데, 트윈은 그것을 낱낱의 관측으로 받아 **더하기만 * 했다.** 「그리고 이것 말고는 없다」를 받는 곳이 없어서 목록에서 빠진 줄이 영원히 남았다. * 실측: 실제 1,704건인데 트윈이 10,038건. * * 그동안 이 줄은 「기능 있음」이었다. **현재 재고를 맞게 유지하지 못하는 상태로 그렇게 적을 수 * 없다.** 오늘 「이 목록이 전부다」를 받는 길을 냈고(커널 0.7.63), 수량으로 온 재고도 없어질 수 * 있게 했다. 이제 사실이다. */ { std: 'MaterialLot', part: '2', label: 'twin.isa95.MaterialLot', axis: 'items', structure: 'partial', behavior: 'full', surface: 'partial', note: 'twin.isa95.note.lot' }, /* * ── 2026-08-24 판정 정정: `none` 이 아니었다 ────────────────────────────── * 이 줄은 셋 다 `none` 이었는데 **표가 코드보다 뒤처져 있었다.** 커널은 서브로트를 든다: * * `ItemState.subLotId` 표준 `MaterialSubLot.ID` (계약이 그렇게 적고 있다) * `subLotIdOf(class, location)` 비직렬 로트가 자리마다 나뉠 때 그 부분의 이름을 만든다 * `itemKeyOf(item)` **개체의 정체성이 이 값이다** — `subLotId ?? epc` * * 기능이 `full` 인 근거: 이 축이 없으면 같은 로트를 rack-1 에 100개·rack-2 에 60개 관측했을 때 * **뒤에 온 관측이 앞을 덮어 100개가 오류 없이 사라진다**(합계 160 → 60). 실제로 그 결함을 이 축으로 * 고쳤다. 즉 장식이 아니라 정체성을 지탱한다. * * 구조가 `partial` 인 이유: 서브로트는 로트와 같은 모양이고(위 참조) 그 로트가 `partial` 이다. * 화면이 `partial` 인 이유: 값이 물품의 **이름으로** 나온다(`twin-item` 의 line) — 「이 로트가 자리마다 * 나뉘어 있다」를 말하는 자리는 아직 없다. * * 이 부류(코드에 있는데 표가 `none`)는 자동으로 잡히지 않는다 — 표의 가드는 반대 방향만 본다 * (축이 사라지면 구조를 내린다). 그래서 축을 늘릴 때 이 표를 함께 보는 것이 규율이다. */ { std: 'MaterialSublot', part: '2', label: 'twin.isa95.MaterialSublot', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'full', surface: 'partial' }, { std: 'ProcessSegment', part: '2', label: 'twin.isa95.ProcessSegment', axis: 'operations', structure: 'full', behavior: 'full', surface: 'full' }, /* * 속성은 자원마다 붙는 확장이라 **자기 축이 아니다** — 그래서 `axis` 는 없지만 구조는 완전하다: * 계약(`ResourceProperty`: 값·데이터형·단위·하위·등급속성)이 표준을 그대로 담고, 인제스트·상태· * 화면이 그것을 통과시킨다. * * 기능은 **부분**이다: 호스트의 주행 추정기가 `speed` 를 읽어 이동 시간을 만드는 것이 유일한 소비처다 * (다른 속성은 아직 아무도 읽지 않는다). 그 사실을 `full` 로 올리면 표가 거짓이 된다. */ { std: 'Property', part: '2', label: 'twin.isa95.Property', axis: null, shownOn: ['equipment', 'persons', 'assets'], structure: 'full', behavior: 'partial', surface: 'full', note: 'twin.isa95.note.property' }, /* * **자리의 상시 관측** — 2026-08-24 신설(커널 0.7.56). 표에 이 개념이 아예 없었다. * * 왜 이 축이 필요한가: 트윈의 판정 대상은 물건의 상태이고, 물건의 상태는 **조건 없이 정해지지 * 않는다.** 「이 로트가 냉장실에 있었다」까지만 아는 트윈은 그 로트가 괜찮았는지 말할 수 없다. 그리고 * **이 조인은 트윈만 할 수 있다** — 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은 조건 * 이력을 모른다. * * 표준 앵커: `OperationsEventType`(B2MML-OperationsEvent.xsd, ISA-95.00.02-2018) + * `OperationsRecordTemplateType`. 우리가 든 것 — `EffectiveTimestamp` · `EffectiveEndDate` · * `RecordTimestamp` · `HierarchyScope`(자리) · `Source` · 값은 `ValueType` 으로 **좁혔다** * (단위 없는 물리량은 판정의 재료가 못 된다). * * 붙는 자리가 `HierarchyScope.EquipmentID` 인 것이 중요하다 — ISA-95 에서 장소 계층이 곧 설비 * 계층이고 냉장실은 `StorageZone` 수준의 설비다. `OperationalLocationType` 에는 이 축이 없다. * * 구조가 `partial`: 봉투 전체(`ID`·`Description`·사건 분류·`OperationsRecord` 구조)가 아니라 관측 * 하나를 담는 데 필요한 칸만 든다. 2026-08-25 에 지난 기록의 봉투가 사람이 적은 문장을 함께 * 나르게 됐지만(`CanonicalEnvelope.description`), 그것은 **기록에 남는 말**이고 이 타입의 * `Description` 칸이 아니다 — 상태에 담기지 않으므로 이 줄의 등급을 올리지 않는다. * 기능이 `full`: 사건 채널(`location.measured`)이 상태를 만들고, 선언된 기준으로 판정해 * `observation-out-of-limit` 신호를 세운다. * 화면이 `none`: 클라이언트에 이 축을 그리는 곳이 없다(실측 0곳). */ /* * ── 2026-08-25 — 이 줄이 실제보다 높게 적혀 있었다 ──────────────────────── * 「기능 있음」으로 적어 두었다. 커널이 관측을 반영하고 기준으로 판단하는 것은 사실이었으므로 * 그렇게 적었는데, **연결된 시스템이 그 값을 보낼 길이 없었다** — 유입 경로가 `locationId` 를 * 보지 않아 「어느 사실인지 모른다」로 거부했다. * * **아무도 먹일 수 없는 기능은 동작하는 기능이 아니다.** 오늘 그 경로를 냈고(커널 0.7.62), * 이제 「기능 있음」이 사실이다. 그때까지는 「일부」였어야 했다. * * 같은 부류를 오늘 세 번 만났다 — 표가 **할 수 있는 일을 못 한다고** 적은 것이 여섯 줄이었고, * 이것은 반대로 **못 하는 일을 할 수 있다고** 적은 것이다. 뒤쪽이 더 위험하다. */ { std: 'OperationsEvent', part: '2', label: 'twin.isa95.OperationsEvent', axis: null, shownOn: ['locations'], structure: 'partial', behavior: 'full', surface: 'none' } ] /* * Part 4 — 일정과 실적. * * **2026-08-11 이전에는 이 부분이 거의 비어 있었다.** 오더·작업은 런타임과 저널에 실재하는데 조회 * 모델의 축이 아니었고, 그래서 "이 트윈이 표준의 어디를 채우나" 라는 질문에 절반만 답했다. * * 커널이 계약을 넓혀(`source: 'state'`) 두 축이 추가되었다 — 이제 요구(`orders`)와 응답(`tasks`)을 개념으로 * 가리켜 걸어 들어갈 수 있다. 남은 것은 **과거로 다시 계산하는 길**(저널 커서 페이징)과 능력·검사 명세다. */ const PART4: Isa95Concept[] = [ { std: 'OperationsDefinition', part: '4', label: 'twin.isa95.OperationsDefinition', axis: 'productionSpec', structure: 'full', behavior: 'full', surface: 'partial', note: 'twin.isa95.note.opsdef' }, /* * 2026-08-11 — **축이 추가되었다**(커널 0.6.3 · `source: 'state'`). 요구는 `orders`, 응답은 `tasks` 다. * * `structure` 를 `full` 로 올린다: 담을 자리가 계약에 있다(상태 계약 + 축 선언). **테이블 투영이 * 없는 것은 결손이 아니다** — 이 축의 정본은 저널이고, 투영하지 않는 것이 결정이다(ADR-0032-D). * * `surface` 도 `full` 이다(같은 날 두 번째 단계): 목록·상세·관계에 더해 **과거로 다시 계산하는 길**이 화면에 * 있다 — 시각 커서를 옮기면 그 시점의 사실이 나오고, 무엇을 근거로 답했는지도 화면이 말한다 * (라이브=권위 · 저널=파생 · 계산한 리비전). 실측: 이른 시각 0건 · 하루 뒤 504건. */ { std: 'OperationsSchedule', part: '4', label: 'twin.isa95.OperationsSchedule', axis: 'orders', structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.schedule' }, { std: 'OperationsPerformance', part: '4', label: 'twin.isa95.OperationsPerformance', axis: 'tasks', structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.performance' }, { std: 'OperationsCapability', part: '4', label: 'twin.isa95.OperationsCapability', axis: null, shownOn: ['operations'], structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.opscapability' }, /* * 2026-08-12 — **축이 추가되었다**(커널 0.6.5). 자원 아홉이 가리키던 참조에 대상이 생겼고, 없는 명세를 * 가리키면 선 채움이 끊어진 참조로 센다. * * 기능이 **`partial` 로 살아났다**(커널 0.6.6): 결과(`TestResult`)가 계약에 들어오면서 커널이 배정에서 * 그것을 본다 — 만료·불합격이면 그 자격은 성립하지 않는다(실측: 만료된 용접사는 용접 작업 5건 중 한 * 번도 배정되지 않았다). * * ── 2026-08-24 위 문장 하나가 낡았다 ───────────────────────────────────── * 「**사람만 판정한다**」고 적혀 있었다. 지금은 아니다 — `capabilityOfResource` 가 설비·사람·자산 * **셋 모두**에 `requiredTests` 를 걸고, 그 답이 배정의 가용성 필터로 들어간다. 그리고 판정 대상이 * 자원 밖으로 넓어졌다: **자리의 상시 관측**(`OperationsEvent` 줄)과 **로트의 시험 결과** * (`TestResult` 줄)도 같은 기준으로 판정한다. * * 남은 규율은 하나다: **결과가 없으면 판정하지 않는다**(선언한 것만 제약이 된다 — 결손이 아니라 규율). * * 그리고 기준의 **숫자 한계는 우리가 더한 것**이다. 표준 `TestSpecificationCriteria.Expression` 은 * 자유 문장(`TextType`)이고, B2MML 일곱 파일에 `Minimum`·`Maximum`·`Tolerance` 가 하나도 없다 * (원문 대조). 그것 없이는 판정할 수 없으므로 `limit: {minimum, maximum, uom}` 을 더했고, 발명한 * 자리임을 계약에 적었다. 커널은 `Expression` 을 **읽지 않는다** — 문법이 정의되지 않았으므로 * 파싱하면 그 순간 방언이다. */ { std: 'TestSpecification', part: '4', label: 'twin.isa95.TestSpecification', axis: 'testSpecifications', structure: 'full', behavior: 'full', surface: 'full', note: 'twin.isa95.note.test' }, /* * **시험 결과** — 2026-08-24 신설(커널 0.7.57). 기준만 있고 결과의 자리가 표에 없었다. * * 표준 `TestResultType`(B2MML-OperationsTest.xsd)에서 우리가 든 것: * `EvaluationDate`(at) · `Expiration`(expiresAt) · `TestableObjectID` · `PropertyMeasurement` * · `EvaluatedCriterionResult` → **`result: 'pass'|'fail'` 로 좁혔다**(표준은 열거하지 않는다) * 아직 없는 것: `ID` · `Description` · `HierarchyScope` · `OperationsTestRequirementID` · * `TestResultChild`. 그래서 구조는 `partial` 이다. * * 기능이 `partial` 인 이유를 정확히 적는다 — **자원에서는 결정을 바꾸고 로트에서는 아직 바꾸지 * 않는다.** 만료·불합격 자원에는 작업이 배정되지 않지만, 불합격 로트가 할당에서 빠지거나 주의 신호를 * 세우지는 않는다(들고·판정하고·되짚을 수는 있다). 그 자리를 `full` 로 적으면 표가 거짓이 된다. * * 화면은 `none` 이다 — 클라이언트에 이 축을 그리는 곳이 없다(실측: `operato-twin/client` 에 0곳). */ { std: 'TestResult', part: '4', label: 'twin.isa95.TestResult', axis: null, shownOn: ['items'], structure: 'partial', behavior: 'partial', surface: 'none' }, { std: 'WorkMaster', part: '4', label: 'twin.isa95.WorkMaster', axis: 'recipes', structure: 'full', behavior: 'full', surface: 'full' }, { std: 'WorkDirective', part: '4', label: 'twin.isa95.WorkDirective', axis: null, structure: 'none', behavior: 'none', surface: 'none' } ] /** 표준이 요구하는 것 전부 — 이 배열이 곧 기준이다. */ export const ISA95_CONCEPTS: Isa95Concept[] = [...PART2, ...PART4] /** * 적합성 보고 — **채운 것과 빠진 것을 함께.** * * `axis` 를 커널 축 목록과 맞춰 본다: 축이 있다고 적혀 있는데 커널에 없으면 그 선언이 낡은 것이므로 * **구조 없음으로 낮추고 그 사실을 말한다**(알리지 않고 통과시키면 이 표가 거짓이 된다). */ /** * 선언한 축을 커널의 축 목록과 맞춘다 — **어긋난 것을 알리지 않고 지우지 않는다.** * * 두 자리가 어긋날 수 있다. * · `axis` — 그 개념 자신의 문. 커널에 없으면 구조를 「없음」으로 낮추고 그 사실을 말한다. * · `shownOn` — 그 사실을 들고 있는 다른 축. 예전에는 없는 축을 **말없이 걸러 냈다.** 그러면 그 줄은 * 「개념 축이 없습니다」로 보이고, 사람은 그것을 **원래 그런 것**으로 읽는다. 실제로 그 일이 있었다: * EPCIS 표의 일곱 줄이 커널에 없는 축(`items`)에 기대고 있었는데, 경고가 뜬 것은 두 줄뿐이었다. * * 둘 중 하나라도 어긋나면 `axisMissing` 이다 — 표가 스스로 「최신이 아니다」라고 말하게 한다. */ export function reconcileAxes(c: Isa95Concept, known: Set): Isa95Concept & { axisMissing?: boolean } { const ownMissing = !!c.axis && !known.has(c.axis) const shownOn = c.shownOn?.filter(a => known.has(a)) const lostShown = !!c.shownOn && (shownOn?.length ?? 0) !== c.shownOn.length const fixed = lostShown ? { ...c, shownOn } : c if (!ownMissing && !lostShown) return fixed /* 구조를 낮추는 것은 **자기 문이 없어진 경우**다 — 남의 축에서 보이던 것은 구조가 아니라 표시의 문제다. */ return ownMissing ? { ...fixed, structure: 'none' as CoverageLevel, axisMissing: true } : { ...fixed, axisMissing: true } } export function isa95Coverage(axes: string[]): { model: string concepts: (Isa95Concept & { axisMissing?: boolean })[] totals: { concepts: number; structure: number; behavior: number; surface: number } } { const concepts = ISA95_CONCEPTS.map(c => reconcileAxes(c, new Set(axes))) /* 채운 수는 `full` 만 센다 — 부분을 채운 것으로 세면 기준이 스스로를 속인다. */ const count = (k: 'structure' | 'behavior' | 'surface') => concepts.filter(c => c[k] === 'full').length return { model: 'ISA-95', concepts, totals: { concepts: concepts.length, structure: count('structure'), behavior: count('behavior'), surface: count('surface') } } }