엔지니어링 로그
본 문서는 일반적인 릴리스 연혁과 달리, 아키텍처 및 구현 상에서 발생한 오류와 결함(Defects), 결함 감지 메커니즘, 그리고 이에 따른 시스템 구조 개선 이력을 일자별로 기록한 엔지니어링 분석 로그입니다.
정본 문서들이 소스 코드의 정본 상태를 기술하며 소스 코드와의 앵커 계약(doc-anchors.yml)을 통해 지속적 정합성을 검증받는 반면, 본 로그는 특정 시점의 아키텍처 상태와 결함 분석 이력을 보존하는 불변 기록(Immutable Log)입니다.
결함 감지 계층별 분석
2026년 7월과 8월 기간 동안 식별된 결함 20건을 **실제 결함을 감지한 검증 계층(Verification Layer)**별로 분류하였습니다. 자동화 검사는 인프라 결함을, 정량 측정은 데이터 및 랭킹 편향을 식별하였으며, 장기간 자동화 검사를 통과하고 있던 중대 결함은 엔지니어의 수동 정밀 검토를 통해 최종 검출되었습니다.
⚠ 본 수치는 해당 기간의 결함 식별 이력을 정합 분석한 것이며, 9월 이후 항목은 시스템 결함이 아닌 관측 데이터에 대한 엔지니어링 해석 오류 및 재교정 이력을 다룹니다.
7월 — 돌고 있지 않던 검사들
한 번도 실행된 적 없는 테스트
2026-07-10 · ci(nexus): run the DB-backed tests — and repair the fixtures that never ran
DB 기반 통합 테스트는 “빨간불이 없다”는 의미에서 건강해 보였다. 그것들은 한 번도 실행된 적이 없었다: 픽스처 스코프 불일치로 setup 단계에서 죽었고, 러너는 그것을 스위트가 견디는 에러로 보고했다. 몇 달치의 “통과”가 실제 DB 를 건드리는 경로에 대해 아무것도 말해 주지 않고 있었다.
수리는 마이그레이션을 적용한 실제 서버에 전체 스위트를 돌리는 Postgres CI 잡을 세웠다. 남은 규칙: 스킵된 테스트는 없는 테스트다. 그리고 무언가 잡는 것을 한 번도 본 적 없는 검사는 동작이 확인된 검사가 아니다. 그 뒤로 가드 검사를 추가하면 일부러 한 번 깨뜨려 빨간불이 되는지 확인한다.
테스트 실행이 실제 코퍼스를 지웠다
2026-07-10 · feat(nexus): document lifecycle across web, MCP and CLI + a guard against wiping a real DB
스위트는 테스트 사이에 테이블을 TRUNCATE 한다. 스크래치 DB 라면 옳은 동작이다. 그것이 개발 DB 를 겨누고 있었고, 그 안의 코퍼스가 사라졌다.
조치 방법은 “연결 문자열을 조심하자”가 아니었다. 이제 DB 가 스스로 일회용임을 명시적 선언해야 한다 — 스위트는 마커 테이블을 찾고, 없으면 하드 실패로 종료한다. 조심은 메커니즘이 아니고, 거부가 메커니즘이다.
8월 — 틀린 것은 측정 쪽이었다
문서에 적힌 숫자가 실행이 낸 숫자가 아니었다
2026-08-04 · docs: correct the coverage figure — 232 chunks was wrong, the run says 10
커버리지 숫자 하나가 출력이 아니라 기억에서 문서로 옮겨져 있었다. 실행을 다시 읽으니 자릿수가 넘게 틀렸다. 그 자체로는 작은 일이고, 여기 적는 이유는 이것이 패턴의 시작이었기 때문이다: 이후 2주 동안 시스템보다 계측기가 더 자주 틀렸다.
명세해 놓고 건너뛴 대조군이 판정을 뒤집었다
2026-08-06 · fix(ko-eval): run the calibration control that was specified and skipped - it overturned a judgement
평가 설계에 음성 대조군이 있었다 — 주장하는 효과가 진짜가 아니라면 같은 결과를 내야 하는 입력. 계획서에 적어 놓고 돌리지 않았다. 주 결과가 설득력 있어 보였기 때문이다. 돌려 보니 결론이 뒤집혔다.
그 뒤로 상시 규칙: 대조군 없는 결과는 결과가 아니다. 그리고 대조군은 판정을 적기 전에 돌린다. 의심이 든 뒤가 아니라.
지웠던 지문이 한 달 만에 돌아왔다
2026-08-07 · chore(scrub): remove a partner organisation's fingerprint, and count it on every push
공개 전환 전에 식별 정보를 스크럽했다. 한 달 뒤, 평범한 작업을 통해 그것들이 돌아와 있었다 — 주소 하나, 실제 페이지 식별자 하나, 그리고 아무도 안 보던 사각지대인 커밋 메시지와 PR 본문 안에.
이제 매 푸시마다 스캐너가 돈다. 얼어붙은 웹훅 페이로드가 아니라 API 에서 현재 제목·본문을 읽으므로 나중의 수정이 빠져나가지 못하고, 음성 대조군을 달고 있어서 아무것도 못 잡게 된 스캐너가 조용히 통과하는 대신 요란하게 실패한다. 허용목록은 예외가 아니라 답변 근거를 받는다: 나중에 어떤 테스트가 여기 걸렸을 때 조치 방법은 합성 식별자를 쓰는 것이었지 허용목록에 줄을 더하는 것이 아니었다 — 사례마다 예외가 하나씩 붙는 목록은 일정표를 가진 구멍이다.
리포에 스키마가 두 개 있었다
2026-08-09 · fix(ci): the recall job never ran migrations, so the repo had two schemas
CI 잡 하나가 베이스라인 파일로 DB 를 만들고 그 위에 마이그레이션을 적용하지 않았다. 즉 어떤 배포도 돌린 적 없는 스키마를 테스트하며 통과하고 있었다.
적재가 아무도 검색하지 않는 컬럼에 썼다
2026-08-11 · fix(nexus): the corpus never said which generation serves it, so two processes disagreed
임베딩 모델·차원·기록 대상 벡터 컬럼은 한 단위다 — 세대. 문서화된 명령을 호스트에서 돌리면 그 단위를 설정 기본값에서 해석하고, 컨테이너는 환경변수에서 해석했다. 둘이 어긋났고, 그래서 완벽하게 성공한 적재가 어떤 질의도 읽지 않는 컬럼에 벡터를 썼다. 아무것도 실패하지 않았다. 문서가 그냥 찾아지지 않았을 뿐이다.
이제 코퍼스는 세대를 DB 에 append-only 로 명시적 선언하고, 도는 프로세스가 다른 세대를 해석하면 문서를 한 건 수집하기도 전에 적재가 거부된다. 같은 변경이 하나를 더 못박았다: 청크 텍스트가 바뀌면 모든 벡터 컬럼이 무효화된다 — 예전 결정 기록이 죽었다고 적어 둔 버그가 컬럼이 하나 늘자 그대로 되살아났다.
숫자는 있었고, 아무에게도 보여지지 않았다
2026-08-11 · fix(nexus): the coverage number existed, and no one was ever shown it
인덱스 커버리지 — 각 검색 경로가 실제로 볼 수 있는 청크가 몇 개인가 — 는 처음부터 정확히 계산되고 있었다. 그것이 API 기동 로그에 적혔고, 그곳은 사람이 보지 않는다. 사람이 실제로 치는 명령은 아무것도 안 보여줬고, 51개 청크의 구멍이 하루를 지나갔다.
이 로그에서 가장 자주 반복되는 모양이고, 지금까지 네 번 따로 나타났다: 감지기는 있고 전달이 없다. 여기서 나온 규칙은, 사람이나 에이전트가 실제로 읽는 무언가가 그것을 보여주기 전까지 그 신호의 값은 0이라는 것 — 그리고 감지기만 검사하면 전달을 지워도 초록이므로 검사가 API 표면을 실행해야 한다는 것이다.
실패의 절반은 내 것이었다
2026-08-12 · feat(nexus): measure a second corpus, and find out half the failures are mine
두 번째 코퍼스에 평가를 돌리자 실패가 깨끗이 갈렸다: 일부는 시스템의 것이었고, 나머지는 채점기 자체의 결함이었다 — 답이 없는 문서를 가리키는 라벨, 네 번째 표현에 그대로 뚫린 기권 탐지기, 검증기가 해소하지 못하는 인용 형식.
기권 탐지기가 교훈적이다. 그것은 알려진 거절 문구의 목록이었고, 다음 실행이 목록에 없는 표현을 냈고, 그래서 정직한 거절이 환각으로 채점됐다. 관측된 문자열의 목록은 탐지기가 아니다. 구조 규칙으로 교체했다: 거절은 답변 근거를 지목하며 부정한다 — 그 문장을 전에 본 적 있는지에 의존하지 않는 규칙이다.
채점기가 더는 시스템을 가르지 못했다
2026-08-13
두 평가 팩이 모두 천장에 닿았다. 앞선 한 주 동안 점수가 올라갔는데 — 그 상승분 전부가 계측기 수리로 추적됐다. 검색도 생성도 건드리지 않았다.
두 팩을 품질 근거가 아니라 회귀 검사로 재분류했고, 리포는 그 총점을 시스템이 얼마나 좋은지의 척도로 인용하기를 그만뒀다. 두 시스템을 가르지 못하는 척도는 그것들을 측정하고 있지 않다.
채점기가 구조적으로 볼 수 없는 결함을 통과시켰다
2026-08-18 · feat(nexus): tell the user when the evidence does not fit the question
자동 점수는 8점 만점에 7.7이었다. 배포된 팀은 쓰지 않았다. 이유를 묻자 한 사람이 답변이 석연치 않다고 했다 — 그 한 문장이 몇 주 동안 모든 자동 검사를 통과해 온 결함을 열었다.
재현: 도구 이름의 유래를 물었더니 시스템이 답변 근거 10칸을 모두 채우고 기술 스택 표와 API 응답 구조를 길게 답했다. 환각이 아니다. 인용은 해소됐고, 그라운딩 검사도, 사실 검사도 통과했다. 가용한 어떤 측정으로도 이것을 볼 수 없었다.
원인은 융합 단계에 있었다. RRF 는 결과를 1/(k + 순위) 로 점수화하므로 순위를 담고 크기를 버린다 — 그런데 두 검색 경로는 정렬하려고 크기를 이미 계산해 놓고 반환에서 버리고 있었다. 되살리니 세 모집단이 깨끗이 갈린다:
| 벡터 거리 | 키워드 점수 | |
|---|---|---|
| 답할 수 있음 | 0.191 – 0.455 | 2.0 – 4.5 |
| 주제는 있고 답이 없음 | 0.377 – 0.470 | 0.8 – 1.2 |
| 코퍼스 밖 | 0.544 – 0.575 | 0.1 – 0.6 |
약한 답변 근거가 답을 막지는 않는다 — 응답 계약만 바꾼다. 그래서 임계값이 틀렸을 때의 비용이 잘못 삼킨 답이 아니라 짧은 답이 된다. 임계값은 저술한 질문 17개에서 나온 가설로 기록돼 있고, 이제 요청마다 두 크기를 저장하므로 언젠가 발명이 아니라 실사용이 그 값을 정할 수 있다.
이 항목에는 정정이 붙는다: 주제는 있고 답이 없음을 대표하도록 쓴 질문 6개 중 2개가 사실은 답할 수 있는 질문이었고, 시스템은 인용을 달고 정확히 답했다. 부재 확인을 코퍼스의 어휘가 아니라 저자의 어휘로 돌린 것이다. 다시 나누니 읽기는 오히려 강해졌지만, 교훈은 이것이다 — 자기 말로 증명한 부재는 부재가 아니다.
어떤 검색 경로도 읽을 수 없는 문서
2026-08-18 · fix(nexus): keep a chunk's generation key with the document it belongs to
soft-delete 된 문서를 되살리면 청크의 “현재 세대만” 복원되고, 그 판정은 청크에 달린 키를 문서의 content hash 와 비교해서 한다. 재적재는 문서의 해시를 갱신하면서 청크의 것은 옮기지 않았다. 그래서 한 번이라도 편집된 문서는 과거에 멈춘 청크를 이고 있었고, 이후의 삭제→복원은 문서를 읽을 수 있는 청크 0건으로 다시 세웠다 — 목록에 뜨고, 개수에 세어지고, 건강하다고 보고되면서 어떤 검색 경로도 한 글자 못 읽는 상태로.
팀이 실제로 질의하는 라이브 코퍼스에 그런 문서가 하나 있었고, 같은 트리거를 기다리는 것이 여덟 개 더 있었다. 그 트리거는 사람의 명령이 아니라 예약된 재조정 작업이다. 이것을 숨긴 사각지대는 이름을 붙여 둘 값이 있다: 커버리지는 청크 단위로 계산되므로, 청크가 0건인 문서는 모집단 밖이라 100% 로 보고된다.
첫 페이지가 바뀐 검색 경로를 계속 서술하고 있었다
2026-08-21
이 사이트 맨 위의 그림은 쿼리가 세 리트리버로 분기해 RRF 로 융합된다고 말하고 있었다. 융합되는 것은 두 경로다. 그래프 조회는 따로 돌고 다양성 컷오프가 끝난 뒤 붙으며, 랭킹에 아무것도 기여하지 않는다.
바로 그 오류는 엿새 전에 이미 발견돼 고쳐져 있었다 — search/hybrid.py 에 앵커로 묶여 감시받는 에이전트 지시 파일에서. 고침이 공개 페이지까지 오지 않은 이유는 하나다: 홈페이지가 앵커 목록에 없었다. 검색 아키텍처에 대해 가장 많이 읽히는 서술이, 드리프트 검사가 유일하게 안 보던 것이었고 그래서 가장 오래 표류했다 — 자기가 서술하는 코드에 34개 커밋이 쌓이는 동안.
두 언어판을 모두 고치고 둘 다 앵커에 올렸다. 일반화는 불편하지만 갖고 갈 값이 있다: 검사는 등록된 것만 지킨다. 그리고 빠뜨리기 쉬운 페이지는 문서가 아니라 홍보처럼 느껴지는 페이지들인데, 그것이 정확히 사람들이 제일 많이 읽는 것이다.
기능을 꺼도 15/15 였다
2026-08-26 · feat(nexus): the answer-fact ruler measured mention, not assertion — split them
검색 지표가 못 보는 것을 측정하려고 새 채점기를 만들었다. 답변에 그 값이 실제로 들어 있는가. 15문항 중 15를 읽었다. 그다음 이 채점기가 평가하려던 검색 변경을 끄고 다시 돌렸더니, 여전히 15/15 였다.
두 사실의 원인은 하나다. 부분일치는 주장된 값과 나열된 값을 구별하지 못한다. 문제의 답변은 근거별 수치를 늘어놓은 표에 4,000 을 적어 놓고, 결론에서 “확인 전까지는 어느 수치도 단정할 수 없습니다” 라고 물러섰다. 채점기는 그것을 정답으로 셌다.
새 버전은 값이 어디에 서 있는지를 본다. 하나는 선두, 첫 표나 인용이나 헤딩보다 앞에 오는 산문이고, 시스템이 자기 프롬프트에서 답을 위해 비워 둔 자리다. 다른 하나는 접속 부사가 여는 결론 세그먼트다. 그 밖은 답변 근거를 늘어놓는 자리로 본다.
팔당 3회, 질의별 다수결, 검정 전에 노이즈 플로어부터:
| 변경 켬 | 끔 | 변동폭 | 격차 | |
|---|---|---|---|---|
| 부분일치 | 15 · 15 · 15 | 15 · 15 · 15 | 0 | 0 |
| 주장 | 14 · 13 · 14 | 9 · 9 · 10 | 1 | 5 |
격차는 잡음을 넘었다. 그래도 부호검정은 돌리지 않는다. 불일치쌍이 5인데 사전등록한 최소치가 6이다. 다섯 건 전부 한 방향이고 반대 방향은 없지만, 주장은 거기서 멈춘다. 회차를 더 돌려도 이 수는 늘지 않는다. 불일치쌍을 정하는 것은 표본 추출이 아니고 질문 세트다.
여기서 나온 규칙은 이제 무엇을 측정하기 전에 먼저 적용된다. 점수를 읽기 전에 “이 채점기가 처치를 볼 수 있는가” 를 묻는다. 1.000 에 붙은 채점기는 좋은 결과가 아니다. 없는 결과다. 같은 회차에서 부분일치의 두 번째 결함도 드러났다. 폐기된 정책의 인용 안에만 나오는 값을, 답변이 전혀 다른 결론을 내는데도 정답으로 센다.
계측기가 우리 자신의 재적재를 세고 있었다
2026-08-26 · fix(nexus): the re-ingest signal counted our own re-runs — give it a denominator
엔트로피 신호 하나는 재수집 덮어쓰기를 센다. 같은 식별 정보 아래에서 내용이 바뀐 문서다. 승인된 아키텍처 결정이 이 수를 demand-pull 트리거로 지정했고 여러 보류 항목이 여기 걸려 있다. 무엇을 만들지가 이 숫자로 정해진다는 뜻이다.
바로 전날 그 뷰를 고쳤다. 전역 집계라 버릴 평가 테넌트가 라이브 신호를 삼키고 있었다. 전역 중복쌍 61,425 대 라이브 0. 테넌트별로 가르는 것이 그 수정이었다.
절반이었다. 라이브 수는 이벤트 53인데 그것이 닿은 문서는 18개이고, 38건이 사흘 안에 몰려 있다. 활성 문서는 126이다. 그 사흘은 적재 파이프라인을 고치며 재적재를 돌린 날이다. 이 신호는 코퍼스가 얼마나 흔들리는지가 아니라, 우리가 같은 열여덟 개를 몇 번 다시 넣었는지를 측정하고 있었다.
수정은 지표를 재정의하지 않고 분모를 더한다. 승인된 결정 문서가 이 신호를 events 로 정의했고, 그 컬럼의 뜻을 조용히 바꾸면 승인 문서와 코드가 갈린다. 53을 혼자 읽던 사람이 이제 53과 18을 함께 읽는다.
못 고치는 것은 마이그레이션 본문에 적어 뒀다. 우리 재적재와 진짜 문서 편집은 둘 다 content hash 를 바꾸므로 저장된 데이터로는 가릴 수 없다. 가리려면 쓰는 시점에 원인을 남겨야 하고, 그건 마이그레이션으로 될 일이 아니고 명세다.
불편한 것은 순서다. 그 뷰를 테넌트별로 가른 사람과 두 번째 겹을 못 본 사람이 같고, 하루 차이다. 계측기의 절반을 닦으면 나머지 절반이 깨끗해 보인다.
돈이 나가는 유일한 경로에 원장이 없었다
2026-08-26 · feat(nexus): book what the screenshot reader spends — the only path that costs money had no ledger
어느 적재 실행이 지출 0으로 보고됐다. 그 실행은 그림 39장을 공급자로 보냈다.
의도적인 왜곡이 아니라 계측 모듈의 결속 누락이 원인이었습니다. 지출 모듈은 존재하였고, 과거 평가 스크립트 2종에만 연결되어 있었을 뿐 적재 파이프라인에는 연결되지 않았습니다. 결과적으로 해당 통계는 수동 계산되었으며, 예외가 발생한 페이지들이 집계에서 누락되었습니다. 공교롭게도 실제 API 호출을 발생시킨 주체가 바로 해당 페이지들이었습니다.
이제 기계 판독은 답변 경로가 이미 자기 것을 적는 방식 그대로 원장에 오른다. 백엔드가 토큰을 돌려주고, 같은 단가표에서 한 번 값이 매겨지고, 실행 단위로 쌓이고, 끝날 때 출력된다. 셋을 일부러 갈라 놓았다. 어느 하나라도 뭉치면 안심하게 만드는 숫자가 나오기 때문이다.
- 캐시 적중은 호출이 아니다. 세면 지출이 부풀려진다
- 실패는 호출이다. 안 세면 “몇 장을 보냈나” 가 틀리고, 그것이 정확히 틀렸던 수다
- 금액 미상은 무료가 아니다. 실행이 단가 없는 모델의 이름을 말하고, 지어내지 않는다
와이어링하다 잠복 결함이 하나 나왔다. 새 인자를 늘 넘기면 그것을 모르는 판독기가 타입 오류를 내는데, 추출 경로는 그림 한 장의 예외를 전부 잡으므로 그 오류가 기계 판독 실패로 삼켜진다. 모든 카운터가 정상을 보고하는 채로 그림이 조용히 안 읽히게 된다. 원장을 안 달면 호출은 이전과 글자 그대로 같게 뒀고, 그 성질을 검사가 박고 있다.
손으로 만든 숫자는 측정이 아니다. 아무도 볼 수 있는 실패 모드가 없다.
이 로그가 합쳐서 말하는 것
개선은 기법 추가가 아니라 결함 제거에서 나왔다. 검색 기법 일곱 개를 시도하고 측정했다 — 다중 홉 검색, 모델 기반 질의 재작성, 빈도 기반 확장, 코퍼스 합치기, 그 밖의 것들. 일곱 전부 측정으로 기각됐다. 이 기간의 실제 이득은 전부 망가진 것을 걷어낸 데서 나왔다: 정답 구절을 잘라내던 다양성 상한, 검색 색인을 오염시키던 추출 마커, 융합에서 버려지던 크기.
같은 모양이 계속 반복됐다: 감지기는 있고 전달이 없다. 커버리지는 계산됐고 보여지지 않았다. 문서↔코드 앵커는 몇 주 동안 쓰이기만 하고 읽는 곳이 없었다. 거절 사유는 뷰 하나만 볼 수 있는 곳에 기록됐다. 지금의 규칙은 감지기만 검사해서는 부족하다는 것 — 검사가 사람이 실제로 보는 API 표면을 실행해야 하고, 빨간불이 되는지 일부러 한 번 깨뜨려 확인해야 한다.
시스템보다 계측기가 더 자주 틀렸다. 이건 계측기에 대한 불평이 아니라, 여기서 계측기를 일급 산출물로 취급하는 이유다 — 서명된 라벨, 사전등록된 판정 규칙, 그리고 점수를 본 뒤에 채점기를 고치는 것에 대한 상시 금지와 함께. 가장 날카로운 사례가 가장 최근 것이다: 만점을 읽으면서 자기가 측정하려던 변경을 못 보던 채점기, 그리고 다른 오염을 고친 바로 다음 날 우리 자신의 재적재를 세고 있던 엔트로피 신호. 둘 다 주장을 그것이 서술하는 대상에 대 보다가 나왔다 — 값싸고, 이제는 뭔가 이상하다고 느낄 때가 아니라 정해진 주기로 한다.
이 페이지는 기록이지 현황판이 아니다. 지금 열려 있는 것은 OPEN.md 를 보라 — 오픈 항목을 세어서, 그것이 늘고 있는지 줄고 있는지 알 수 있게 해 둔 파일이다.
하니스가 아무도 안 지나는 경로를 측정하고 있었다
2026-08-29 · fix(nexus): the harness was measuring a path no user takes
라벨 둘이 실험 스크립트에서 5/5, 4/5 로 통과했다. 같은 라벨을 평가 하네스로 돌리자 통째로 실패했다.
같은 코퍼스, 같은 질문, 같은 채점 규칙이었다. 다른 것은 답변 근거를 만드는 방법 하나였다 — 실험은 답변 경로가 쓰는 함수를 불렀고, 하니스는 자기가 직접 조립했다. 그래서 하니스에는 그 주에 들어간 보강 둘이 빠져 있었다. 하니스가 내는 모든 수가 아무도 지나지 않는 시스템을 서술하고 있었다.
같은 함수로 돌리자 두 라벨이 통과로 돌아왔다. 그게 증거다.
이 리포는 같은 모양을 이미 여러 번 적었다 — 등록이 실행 가드 검사 뒤에 있던 것, 사본이 회귀 검사 밖이던 것, 와이어링이 세 경로 중 하나에만 붙던 것. 새로 배운 것은 그 목록에 측정 도구 자신이 들어간다는 점이다. 측정이 프로덕션과 다른 길을 가면, 그 측정은 초록인 채로 아무것도 지키지 않는다.
계측기가 없는 병을 만들었고, 나는 그 병을 고치러 갔다
2026-08-29 · fix(nexus): a value has surface forms, and one missing form invented a defect
라벨에 기대값을 한 가지 표기로만 적었다. 시스템은 다른 표기로 옳게 답했다. 채점기는 세 번 다 실패로 찍었다.
여기까지는 흔한 라벨 결함이다. 값이 나간 곳은 그다음이다. 나는 그 실패를 시스템 결함으로 읽었다 — “답변 근거를 쥐고도 스테일 쪽을 고른다”. 그 진단에 맞는 조치 방법을 설계하고, 프롬프트 조항을 만들고, 실행군마다 다섯 번씩 돌려 측정했다. 사전에 박아 둔 채택 규칙이 그것을 기각했다. 고칠 병이 없었기 때문에 당연한 결과였다.
실패한 답변을 한 번 읽자 3분 만에 끝났다. 그전까지 나는 점수만 보고 있었다.
그래서 규칙 하나가 늘었다: 실패를 진단하기 전에 실패한 답변을 읽는다. 점수는 어느 값이 빠졌는지까지만 말하고, 그 값이 정말 빠졌는지는 말하지 않는다. 표기를 넓혀 다시 측정하니 그 보강의 실제 효과는 처음 보고한 3/5 이 아니라 0/5 → 5/5 였다.
정직하게 만들려던 수정이 실물 모순을 덮었다
2026-08-29 · eval: labels say what they are measured against, and a "fix" that hid a conflict
문서 코퍼스로 채점하면서 “제품이 실제로 어떻게 동작하나” 를 묻는 문장을 쓰고 있었다. 그러면 정확한 인용을 단, 현실과 다른 답이 만점을 받는다. 그래서 라벨마다 무엇을 권위로 삼는지 적고, 질문 문장도 “문서 기준으로” 라고 밝히도록 고쳤다.
그 수정을 측정에 걸었더니 이렇게 나왔다:
| 갈린 문서가 답변 근거에 | 답변 | |
|---|---|---|
| 원래 문구 | 들어옴 | 두 값을 다 내고 불일치를 표시 (2/2) |
| 권위 접두 | 빠짐 | 한 값만 단정 (2/2) |
접두어가 검색을 좁혀 모순을 담은 문서가 후보에서 통째로 빠졌다. 답변은 받은 답변 근거에 충실했을 뿐이다. 답변 근거 도달은 회차와 무관한 결정성이므로 잡음이 아니다.
되돌렸다. 권위는 메타데이터로만 적는다. 라벨의 질문 문구는 자유 변수가 아니다 — 건드리면 검색이 바뀐다. 정직하게 만들려던 문구가 이 도구가 잡으라고 만들어진 바로 그것을 가릴 뻔했다.
만들어 놓고, 읽는 쪽을 안 만들었다
“닉네임 길이 상한이 얼마인가” 를 묻자 문서 둘이 다른 값을 말했다. 코드를 열어 보니 서버는 그 값을 아예 검증하지 않았고 컬럼만 20자로 잘려 있었다. 문서 둘 중 하나를 고르는 문제가 아니었다.
사용자가 정리해 준 요구는 판정이 아니라 병치였다 — “구현이 문서를 어긴 것일 수도 있고 문서가 갱신되지 않은 것일 수도 있다. 그러니 둘 다 알려줘야 한다.” 그 기능이 있는지 확인했더니 네 계층 중 셋이 끊겨 있었다.
| 계층 | 상태 |
|---|---|
| 코드 값 해석기 | 있다 |
| 코드 트리 마운트 | 있다 |
| 등록된 개념 | 0행 — 시드 파일은 예시 앱용이었다 |
| 답변 경로 연결 | 없다 — 전용 CLI 하나에서만 닿았다 |
고치면서 셋이 더 나왔다. 해석기가 읽는 모양이 실물과 달랐다 — static final 상수만 읽는데 실제 Java 에서 길이 상한은 @Size(max = 100) 과 @Column(length = 20) 에 산다. 대 보려던 값에 하나도 닿지 못하는 상태였다. 비용을 틀린 곳에서 찾았다 — 첫 해석 55.9초의 범인이 새 파싱인 줄 알고 사전 거르기를 넣었는데 개선이 0 이었다. 나눠 측정하니 목록 만들기 50.70초 · 읽기 5.28초 · 파싱 0.18초로, 바인드 마운트를 훑는 비용이었다. 시드가 죽은 claim 을 살아 있는 것처럼 보여줬다 — 11건 적재 만 찍었고, 코드에 안 붙어도 행은 들어간다.
결과는 이렇게 나온다.
**50자** 제한입니다 — 단, 코드와 불일치가 있어 함께 안내합니다.⚠ 서버 요청 검증 코드의 상한은 500자로, 문서의 50자와 10배 차이가 납니다.어느 쪽이 맞는지는 제공된 근거만으로 판정할 수 없습니다.세 값이 갈렸다 — 파티 이름 30 대 100 · 파티룸 소개 50 대 500 · 닉네임 12 대 20. 시스템은 어느 쪽도 고르지 않는다. 계층이 달라 둘 다 맞을 수도 있어서다. 제품 규칙 30, 서버 방어선 100, 컬럼 255 는 이 코드베이스에 실제로 있는 모양이다.
정정 (2026-08-31). 위에서 나는 닉네임에 “서버 검증 없음” 이라고 적었다. 거짓이었다. 사용자가 “제한이 없다고? 테스트 코드에도?” 라고 되물어 확인해 보니 검증은 네 겹으로 있었다 — 사용자 경로 UpdateMyBioRequest @Size(max = 20), 도메인 값 객체 Nickname 이 20 초과에 예외, 컬럼 varchar(20), 그리고 21자가 실패하는 테스트까지. 코드는 일관되게 20 이다.
내가 본 것은 관리자·봇 생성 경로뿐이었다. 그리고 사용자 경로를 놓친 데는 기계적인 이유가 있었다 — 필드 선언을 찾는 정규식이 private|protected|public 으로 시작할 것을 요구했는데, 그 클래스는 Lombok 을 써서 제어자를 안 붙인다.
@Getterpublic class UpdateMyBioRequest { @Size(max = 20, message = "닉네임은 20자를 초과할 수 없습니다") String nickname; // ← 제어자가 없다그래서 없는 것을 없다고 읽은 게 아니라, 못 읽은 것을 없다고 읽었다. 두 상태를 가르지 못하는 도구가 확신하는 문장을 내면 그게 나온다. 지금은 타입 토큰만 요구하고 제어자는 요구하지 않으며, 실물 모양이 검사로 박혀 있다. 심은 claim 이 4/7 에서 6/7 로 붙었다.
⛔ 남은 한계 하나를 같이 적는다. 도메인 값 객체의 if (value.length() > 20) throw … 는 상수도 어노테이션도 아니라 지금도 못 읽는다. 위 20이 세 겹인 것은 사람이 확인한 것이지 도구가 낸 것이 아니다.
전수 확인 (2026-08-31). 정정한 김에 문서값 11개를 전부 코드에 대 봤다. 일치 4 · 불일치 6 · 코드 자리를 못 찾은 것 1 — 내가 앞서 “셋”이라고 적은 것은 전수가 아니었다. 그리고 이력이 단서를 준다: 불일치 넷(파티 이름 100 · 파티룸 소개 500 · 디제잉 최소 1분 · 플레이리스트 이름 100)이 한 커밋에서 왔다 — DTO 18개에 검증을 한꺼번에 붙인 일괄 작업이다. 필드별 제품 결정이 아니라 일괄 기본값으로 보인다는 뜻이고, 그 판단은 값만 봐서는 안 나온다. ⛔ 못 찾은 하나는 “없다”가 아니라 “못 찾았다” 로 적었다 — 방금 그 둘을 헷갈려서 틀린 참이다.
이력이 답을 줬다 (2026-08-31). 소유자가 “파티 이름 100, 소개 500은 말이 안 된다” 며 변경 이력을 물었다. 파 보니 한 번도 바뀐 적이 없었다. 두 파일은 2024년부터 있었고, 크기 검증이 붙은 것은 2026-03-09 한 번뿐이다 — 그전에는 검증이 아예 없었다. 그러니 100·500은 낡은 규칙의 잔재가 아니라 검증을 처음 붙이던 날 고른 기본값이다. 값만 봐서는 둘을 못 가른다.
그리고 파는 김에 하나가 더 나왔다. 소개 검증은 @Size(max = 500) 인데 컬럼은 varchar(255) 이고, 엔티티에 재정의가 없으며 ddl-auto: validate + Flyway 다. 256~500자 소개는 검증을 통과하고 저장에서 실패한다 — 문서 불일치가 아니라 깨진 것이다.
⛔ 그리고 앞서 “코드 자리를 못 찾았다” 고 적은 공지 제한은 있었다. @Size(max = PartyroomData.MAX_NOTICE_CONTENT_LENGTH) 로 상수를 참조하고 있어서 내 검색이 못 봤다. ‘못 찾았다’ 로 적어 둔 것이 이번엔 값을 했다.
닉네임 20의 기원 (2026-08-31). 소유자가 닉네임도 이력을 물었다. 끝까지 파니 이렇게 나온다.
| 때 | 무엇 |
|---|---|
| 2023-05 ~ 09 | 컬럼 varchar(255) — 제한이 없다 |
| 2023-07-30 · 08-31 | 문서 개정 두 건, 닉네임 제한 관련 (문서가 12자 · 8·16 을 말하는 시기) |
| 2024-06-12 | @Column(length = 20) 이 처음 등장 — 커밋 제목은 “Add temporary users in local profile” |
| 2026-02-15 | 요청 검증 @Size(max = 20) 추가 |
| 2026-02-20 | 도메인 Nickname 이 20 초과에 예외 |
| 2026-04-19 | Flyway baseline 이 varchar(20) 을 고정 |
20은 한 번도 제품 결정으로 정해진 적이 없다. 2024년에 임시 사용자 기능을 넣다 붙은 컬럼 길이이고, 2026년의 두 번은 그것을 복사했다. 그리고 코드에 12나 8·16 이 있었던 적은 없다.
같은 2026-02-15 커밋이 소개글은 “to match frontend limit” 이라며 30→50 으로 올렸다. 한 커밋 안에서 한 필드는 제품 규칙을 따라갔고 다른 필드는 컬럼을 따라갔다. 값만 보면 둘 다 그냥 숫자다.
전수는 절반이었다 (2026-08-31). 소유자가 “라벨 18개도 전수조사를 다시 했었나” 라고 물었다. 안 했다. 코드에 대 본 것은 값 라벨 11개뿐이고, 권한 4개와 설계 3개는 손도 안 댔다. 그러고도 나는 “전수” 라고 적었다.
남은 일곱을 마저 대 봤다. 권한 넷은 전부 일치한다 — 파티룸 폐쇄는 방장 본인만(validateHost), 공지는 CM 이상(isBelowGrade(COMMUNITY_MANAGER)), 역할 부여와 밴은 Mod 이상(isBelowGrade(MODERATOR)). 설계 셋은 애초에 코드 확인을 달고 쓴 라벨이고 재확인해도 성립한다.
값은 여섯이 갈리는데 권한은 하나도 안 갈린다. 그럴 만한 이유가 있어 보인다 — 권한은 틀리면 바로 사고가 나는 자리고, 길이 제한은 아무도 안 보는 사이 컬럼 폭으로 흘러간다.
⛔ 그리고 하나 더 나왔다. “라벨 18개 사인오프” 는 내가 매 보고에서 말해 온 오픈 항목인데 오픈 항목 목록에는 없었다. 말로만 굴리고 기록에는 안 올려 둔 것이다.
정답을 안 대 봤다 (2026-08-31). 소유자가 세 번 물어서야 알아들었다. 나는 라벨을 코드에 대 보는 일만 하고, 라벨의 정답 자체가 코퍼스와 맞는지는 한 번도 확인하지 않았다. 그 정답은 내가 쓴 것이다.
대 보니 열여덟 중 이렇게 나온다.
| 확인됨 | 12 |
| 틀린 라벨 | 1 |
| 답변 근거가 갈림 | 1 |
| 청크 하나에만 걸림 | 4 |
B-4 가 틀렸다. 매트릭스를 행 단위로 읽으면 두 행이 다르다.
킥 (재입장 가능) | O | O | O | X | X | ← Mod 가능밴 (재입장 불가능) | O | O | X | X | X | ← Mod 불가나는 밴을 물어 놓고 킥의 값을 정답으로 적었다. 그리고 이 정정이 결함 하나를 연다 — 코드는 두 행을 안 가른다. 게이트 하나(isBelowGrade(MODERATOR))가 일회성 퇴장과 영구 퇴장을 함께 받는다. 즉 코드에서는 Mod 가 영구 밴을 걸 수 있는데 문서는 Admin·CM 만이라고 한다.
⛔ 바로 앞 회차에 나는 “권한 넷은 전부 일치” 라고 적었다. 그건 틀린 라벨로 대 본 결과였다. 라벨이 틀리면 대조도 같이 틀리고, 틀린 방향은 언제나 “문제 없음” 쪽이다.
그리고 A-2 는 같은 문서에서 50자 와 60자 가 둘 다 읽힌다. 넷은 청크 하나에만 걸려 있다 — 틀렸다는 뜻이 아니라 대조군이 없다는 뜻이다.
안전장치가 필요한 자리에만 없다 (2026-08-31). 소유자가 “정원 200명 라벨은 왜 그대로냐” 고 물었다. 라벨은 사전 등록 규칙대로 그대로 두는 것이 맞다 — 이 라벨들은 “문서를 정확히 읽는가” 만 측정하고 “제품이 실제로 그런가” 는 측정하지 않으며, 불일치는 점수가 아니라 보고 대상이라고 규칙에 박혀 있다.
그런데 그 질문을 라이브로 눌러 보니 더 나쁜 것이 나왔다.
"파티룸 정원은 몇 명이야?" → 200명 만 답한다 (소유자 결정: 50 이 맞다)"디제잉 최소 몇 분?" → 문서 3분 · 코드 1분 둘 다 답한다차이는 claim 을 심었느냐가 아니라 값이 어떤 모양으로 코드에 있느냐다. 디제잉 최솟값은 @Min(value = 1) 이라 읽히고, 정원은 if (activeCrewCount > 49) 라 안 읽힌다. 즉 문서가 틀린 것이 확정된 값에서 바로 그 안전장치가 빠진다.
같은 처지가 셋 더 있다 — 자동 폐쇄 30일(메서드 인자), 폐쇄 배치 3시(메서드 어노테이션), 닉네임 도메인 가드 검사. 지금은 문서와 코드가 맞아서 안 보일 뿐이다.
⛔ 그리고 소유자가 정원을 50으로 결정한 그 자리에서 내가 라벨 셋(A-5·A-6·B-4)의 처지를 짚었어야 했는데 안 했다. 규칙대로 둔 것과 아무 말도 안 한 것은 다르다.
사인오프할 게 거의 없었다 (2026-08-31). 소유자가 물었다: “근거 문서명을 네가 밑에 적어놨잖아. 그럼 그게 맞겠지. 이건 문서를 정확히 읽는가만 측정하는 거라며.”
세어 보니 그 말이 맞았다. 라벨 18개의 근거 문구 중 15개는 코퍼스 원문 그대로다 — 기계가 대조할 수 있고 사람이 볼 이유가 없다. 사람 판단이 실제로 필요한 자리는 셋뿐이었다: 코퍼스가 자기끼리 갈리는 값 하나, 어느 문서가 기준인가 하나, 그리고 내가 O/X 필드를 읽어 옮긴 권한 매트릭스.
매트릭스가 유일하게 인용이 아닌 자리이고, 결함 넷 중 셋이 거기서 나왔다.
| B-4 | 밴을 물어 놓고 킥의 행을 읽었다 |
| B-3 | 질문이 중의적이다 — 부여하는 쪽인지 받는 쪽인지 문장이 안 정한다. 그리고 근거 행은 클러버 권한 부여 인데 질문은 역할 부여 일반이었다 |
| A-11 | 문서는 소개 라고 부르는데 나는 상태 메시지 라고 물었다 |
B-3 의 중의성은 소유자가 잡았다 — “수여자부터 판단하는 게 맞지.” 코드가 일반 규칙이라 답은 어차피 같았고, 그래서 안 고치고 넘어갈 수도 있었다. 그러면 다음에 코드와 문서가 갈릴 때 무엇을 물었는지 아무도 모른다.
⛔ 세 결함 다 사인오프 전에 나왔다. 점수가 아직 없어서 라벨을 고쳐도 조작이 아니다 — 규칙이 점수를 사인오프 뒤로 미뤄 둔 이유가 이것이다. 다만 그 규칙이 지켜 준 것은 순서이고, 결함을 찾아낸 것은 사람의 반문 세 번이었다.
사인오프 직후 첫 점수가 채점기를 고발했다 (2026-08-31). 소유자가 사인오프하자마자 18개를 돌렸다. 정책 15개에서 A-10 이 언급=실패 · 주장=통과 로 찍혔다. 2판은 1판의 부분집합이라 그 조합은 성립할 수 없다.
결함은 둘이었고 둘 다 내 코드다. type: conflict 하위 범주가 ok 를 다시 계산하지 않아 모순 라벨은 어떤 답으로도 1판을 통과할 수 없었고, mentioned 는 expect 가 없는 라벨에서 무조건 False 였다. 그 모듈에는 검사가 하나도 없었다.
고치고 다시 돌렸다.
| 첫 회차 | 고친 뒤 | |
|---|---|---|
| 1판 언급 | 14/15 | 15/15 |
| 2판 주장 | 12/15 | 13/15 |
첫 회차의 14/15 는 폐기했다. 한 번 기록된 수치는 지속적으로 인용된다 — 이 리포지토리가 과거 평가 라벨의 데이터 정합성 이슈를 승계받았던 전례가 있어, 사인오프 전에는 점수 지표를 산출하지 않도록 가드 검사를 배치해 두었습니다. 그러나 해당 보호 장치가 사인오프 직후의 회귀를 방어하지 못했습니다.
그리고 남은 실패가 둘 다 결함을 하나씩 물고 있었다.
B-4 — 답변이 Mod 라고 답하며 내가 안 본 표를 인용했다. 권한 매트릭스가 두 개고 밴 행이 서로 다르다: 한 문서는 Admin·CM, 다른 문서는 Admin·CM·Mod. 코드는 Mod 다. 나는 표 하나만 보고 라벨을 Mod→CM 으로 “고쳤”던 것이고, 닉네임 때와 똑같은 실수다 — 한 곳을 보고 단정했다. 두 번째다.
A-3·A-5 — 1판은 통과하고 2판만 떨어진다. 답이 틀려서가 아니라 문서 값과 코드 값을 나란히 내면서 결론 자리에 “둘이 다르다”를 적기 때문이다. 2판은 문서 값이 선두나 결론에 서야 통과하도록 만들었고, 그건 코드 값이 붙기 전의 답변 모양을 전제한 규칙이다. 답이 좋아지자 점수가 떨어졌다.
⛔ 그 규칙은 지금 안 고친다. 점수를 보고 채점 규칙을 고치면 그건 조작이다. 다음 회차 전에 사전 등록으로 바꾼다.
공개 목록에 남의 회사 값이 쌓이고 있었다 (2026-08-31). 소유자가 짚었다 — “내가 할 일은 메모리에 저장해둬. 이 제품을 위해서 처리해야 할 일은 아니잖아.”
맞는 말이었고, 이유가 하나 더 있었다. OPEN.md 는 khala 의 오픈 항목 목록이고 공개 리포에 있다. 그런데 지난 며칠 동안 내가 거기 쌓은 것은 팀 제품의 화면 이름, 필드 이름, 길이 제한, 권한표 행이었다. khala 가 찾아낸 것은 맞지만 khala 의 할 일은 하나도 아니었다.
여섯 줄을 운영자 메모(리포 밖)로 옮기고, 자리에는 “여기 없다, 다시 옮기지 마라” 한 줄만 남겼다.
그리고 그 한 줄을 쓰다가 지문 검사가 나를 잡았다. 옮겨 간 메모 파일 이름에 조직명을 적었고, fingerprint_scan.py 가 OPEN.md:89 — 파트너 조직명, 이것은 공개된다 로 막았다. 이 검사는 2026-08-07 에 지문이 한 달 만에 재유입돼서 만든 것이다. 오늘은 경계를 정리하는 커밋 자체에서 걸렸다.
⛔ 이 리포의 결함 목록은 khala 것만 담는다. 남의 조직 사실은 값 하나하나가 지문이다.
만들려던 것 둘이 이미 있었다 (2026-08-31). 컷오버 SPEC 이 선행 조건 넷에 막혀 있었다. 둘은 소유자 서명이었고, 나머지 둘은 내가 만들어야 할 것으로 적어 뒀다 — 조각별 등급 판정과 새 수명주기 사유(그리고 그것을 처분할 ADR 하나).
만들기 전에 측정하고 찾아봤다. 둘 다 필요가 없었다.
등급 — 두 테넌트의 등급 분포를 대 보니 값이 INTERNAL·RESTRICTED 둘뿐이고 한쪽에만 있는 값이 없다. 그리고 RESTRICTED 네 건을 열어 보니 서로 사본이다. 즉 이 컷오버는 누구의 노출도 바꾸지 않는다. 조각별 판정은 어휘가 실제로 갈릴 때 — 두 번째 조직이 붙을 때 — 필요하다.
제거 수단 — resource_status enum 에 soft_deleted 가 이미 있고, hide_document(= soft_delete + hold=true)와 restore_document 가 이미 있다. 네 요구(검색에서 빠질 것·행은 남을 것·역연산이 있을 것·이유가 남을 것)를 그대로 만족한다. 덤으로 재조정이 hold=true 를 안 되살린다 — 사본이 조용히 돌아오는 경로 하나가 이미 막혀 있었다.
⛔ 나는 새 프리미티브 둘과 ADR 하나를 만들려 했고, 그 전에 찾아보지 않았다. 크리틱이 “자리가 아니다” 라고 밀어낸 덕에 찾아봤고, 자리가 아닌 정도가 아니라 이미 있었다.
이 로그에 이미 적힌 문장과 같은 자리다 — “RAG 검색 난제는 대개 이미 풀려 있다. 리포 내부 문서부터 뒤져라.” 그때는 남의 논문이었고 이번엔 내 리포의 함수였다.
내가 박은 규칙이 나를 멈춰 세웠다 (2026-08-31). 컷오버 전에 베이스라인을 다섯 번 돌렸다. 사전 등록 규칙은 이랬다 — 회차 간에 한 번이라도 흔들린 라벨은 판정에서 빼고, 잔존이 12개 미만이면 부착을 보류한다.
라벨 18 → 흔들리지 않음 11 · 변동성 711 < 12 → 보류규칙은 점수를 보기 전에 썼고, 그것이 발화했다. 사본은 안 내렸다 — 공백 구간에 들어가지도 않았다.
변동성의 정체가 뚜렷하다. 일곱 중 다섯은 2판만 흔들리고 1판은 고정 통과다. 답변이 회차마다 문서 값으로 시작하기도 하고 “문서와 코드가 다르다” 로 시작하기도 하는데, 2판은 값이 선두나 결론 자리에 서야 통과시킨다. 그 규칙은 답변에 코드 값이 붙기 전의 모양을 전제로 쓴 것이다. 답이 좋아진 것이 계측기를 흔든다.
⛔ 그리고 여기가 미끄러지기 쉬운 자리다. 규칙이 나를 막은 것을 보고 그 규칙을 고치면 조작이다. 고칠 근거는 이미 있다 — 이 드리프트는 오늘 아침 A28 로 등록됐고, 개정은 “다음 회차 전에 사전 등록으로” 라고 적혀 있었다. 그래도 순서를 지킨다: 개정을 먼저 등록하고, 그다음 T0 를 다시 돌린다. 지금 상태에서 12를 11로 낮추는 것은 사전 등록을 장식으로 만드는 일이다.
컷오버를 완주했다가 되돌렸다 (2026-08-31). 규칙이 두 번 발화했다.
| 단계 | 결과 |
|---|---|
| T0b 베이스라인 5회 | 안정 라벨 14 (≥12, 통과) |
| 사본 122건 내림 | soft_deleted + hold=true, 활성 청크 0 |
| T1 5회 | 안정 14 중 1개만 변함 — 설계 라벨 하나가 떨어졌다. 술어가 사본을 잡았다는 증거이고, 정책 13개는 그대로다 |
| 범위 부착 | 슬랙이 사본 없이 정본을 읽는 것을 라이브로 확인 |
| T2b 5회 | A-9 가 T0b 에서 5/5 고정이었는데 2판이 갈렸다 → 되돌림 |
되돌림은 한 줄이었다. 사본도 설정도 원상복구했고 라이브로 확인했다.
도중에 나온 결함 셋
① 모델 기본값이 계약을 삼켰다. AnswerRequest.tenant 의 기본값이 "default" 라, 봇이 안 보내도 채워진다. 그것을 “요청했다” 로 읽으면 “안 주면 범위 전체” 가 영원히 발화하지 않는다. 잠금을 열고 설정을 넣고 재기동까지 했는데 답변이 안 바뀌어서야 드러났다. 단위 검사는 요청을 손으로 만드니 필드가 항상 설정돼 있고, 프로덕션도 항상 설정돼 있는데 이유가 정반대다.
② 하니스가 다른 코퍼스에 묻고 있었다. 사본을 내리자 설계 라벨이 떨어졌고 규칙대로면 되돌리기였다. 그런데 같은 질문이 라이브에서는 정답을 냈다 — 하니스는 테넌트 문자열 하나를 검색에 그대로 넘기고 라이브는 읽기 범위를 해소한다. 네 줄 아래 주석에 사흘 전 같은 실수가 적혀 있었다.
③ 계측기가 답변 개선에 흔들렸다. 2판은 값이 선두나 결론에 서야 통과시키는데, 답변이 문서 값과 코드 값을 함께 내면서 확정 문장이 절 안으로 밀렸다. 개정은 사전 등록으로 했고, 구현하자마자 이 리포에 이미 있던 대조군이 개정을 좁혔다.
남는 것
A-9 가 왜 흔들렸는지는 아직 모른다. 설계 코퍼스가 답변 근거에 섞이면서 답변 구성이 바뀐 것일 수도, 그 라벨이 원래 경계에 있었던 것일 수도 있다. 원인을 밝히기 전에는 다시 켜지 않는다.
⭐ 이 회차에서 값진 것은 컷오버가 아니라 막힌 자리 셋이다. 셋 다 “돌긴 도는데 아무도 안 지나는 경로를 측정하고 있었다” 는 같은 얼굴을 하고 있었다.
되돌린 것은 오경보였다 (2026-08-31 저녁). 컷오버를 규칙대로 되돌린 뒤, 되돌린 상태 — T0b 와 같은 조건 — 에서 문제의 라벨을 여덟 번 더 돌렸다.
1판: 8/8 값은 매번 나온다2판: 4/8 확정 위치가 반반이다그 라벨은 원래 흔들리고 있었다. T0b 의 5/5 는 운이었고, 컷오버가 회귀를 만든 것이 아니다.
계산해 보면 당연하다. 통과율이 0.5 인 라벨이 n회에서 균일하게 보일 확률은 2 × 0.5ⁿ 이다.
| n | 균일하게 보일 확률 | 라벨 18개 중 기대 오분류 |
|---|---|---|
| 3 | 25.00% | 4.50 |
| 5 | 6.25% | 1.12 |
| 10 | 0.20% | 0.04 |
n=5 는 오분류를 하나 기대하는 설계였고 정확히 하나 났다. 크리틱이 “회차 수와 추정 방식을 정하고 탐지 가능한 최소 변화량을 적어라” 고 했는데, 나는 3을 5로 올리기만 하고 검정력을 계산하지 않았다. 숫자를 미리 적었다는 사실이 그 숫자가 옳다는 뜻은 아니다.
변동성의 정체는 채점기가 아니라 답변 형식의 비결정성이었다.
통과: **문서(설계) 기준: 20자** ← 절 머리에 확정실패: | 문서 (화면 정책) | 20자 | ← 표 행에만 있다표 행을 주장으로 안 세는 것은 설계대로다. 둘 다 좋은 답인데 형식이 갈려서 라벨이 경계에 선다. ⛔ 답변 형식을 계측기에 맞추지 않는다 — 그건 거꾸로다.
개정은 사전 등록했다: 안정성 분류를 10회로 한 번 하고 시점별 비교는 5회로 두되 분류된 라벨만 본다. 분류는 라벨과 시스템의 성질이지 시점의 성질이 아니다.
앞 항목의 원인 규명을 철회한다 (2026-08-31 저녁). “그 라벨은 원래 흔들리고 있었다” 고 적었는데, 그 결론은 라벨 하나만 골라 돌린 실행에서 나왔다. 전체를 도는 분류 10회에서는 10/10 으로 고정이다.
| 실행 방식 | 2판 |
|---|---|
| 그 라벨만 ×8 | 4/8 (두 번 재현) |
| 전체 15개 ×10 | 10/10 |
차이가 재현된다. 원인은 아직 모른다 — 하니스의 필터는 질의 목록만 거르고, 키리스 브리지는 세션을 안 남긴다. 남은 후보는 벡터 경로가 ANN 이라 후보 집합이 프로세스 상태에 흔들리는 것 정도인데 확인 안 됐다.
무엇이 살아남는지 갈라 적는다.
- ⛔ “그 라벨이 경계에 있다” 는 철회한다. 부분 실행에서만 본 것이다.
- ✅ 검정력 산수(
2×0.5ⁿ, n=5 면 18개 중 기대 오분류 1.12)는 그대로다. 그 라벨과 무관하다. - ✅ 분류와 비교가 둘 다 전체 실행이므로 이번 측정은 내부적으로 일관된다.
⚠ 그리고 규칙 하나가 늘었다 — 부분 실행으로 얻은 수를 전체 실행 조건에 인용하지 않는다. 진단할 때 한 라벨만 돌리는 것은 빠르고 편한데, 그렇게 얻은 수는 다른 조건의 것이다. 오늘 나는 그 수로 공개 기록에 원인을 적었다.
세 번 막히고 나서야 판정 설계를 의심했다 (2026-09-01). 컷오버가 같은 자리에서 세 번 되돌아갔다. 매번 1판은 매번 통과하고 2판만 갈리는 라벨 하나 때문이었다. 세 번째에 산수를 했다.
통과율 p 인 라벨이 10회에서 균일하게 보일 확률 × 그 뒤 5회에서 갈릴 확률:
| p | 10회 균일 | 다음 5회 변동성 | 라벨 18개 기대 오경보 |
|---|---|---|---|
| 0.50 | 0.2% | 93.8% | 0.03 |
| 0.80 | 10.7% | 67.2% | 1.30 |
| 0.90 | 34.9% | 40.9% | 2.57 |
| 0.95 | 59.9% | 22.6% | 2.44 |
“n회 균일” 은 “결정론적” 이 아니다. 그 분류가 걸러내는 것은 p≈0.5 뿐이고, p=0.9 짜리는 세 번에 한 번 균일하게 보인 뒤 41% 확률로 다음 비교에서 갈린다. 비교마다 오경보 두세 개를 기대하는 설계였다.
⛔ 그리고 앞선 개정이 틀린 차원을 늘렸다. 5회를 10회로 올리면서 “이제 기대 오분류가 0.04” 라고 적었는데, 그 계산은 p=0.5 를 가정한 것이다. p=0.9 는 회차를 아무리 늘려도 집합 동일성 판정을 계속 통과한다. 숫자를 계산했다는 사실이 옳은 것을 계산했다는 뜻은 아니었다.
개정은 사전 등록했다 — 집합 동일성 대신 비율로 판정하고, 탐지 가능한 최소 변화량을 명시한다: 이 하니스는 1.0 → ≤0.5 만 잡고 그보다 작은 변화는 못 잡는다. 그리고 2판은 배포 게이트에서 개선 게이지로 내린다 — 답변 형식이 비결정적인 한 2판으로 배포를 막으면 배포가 영원히 안 된다. 부재에 대한 회귀 검사는 1판이 지킨다(지금까지 한 번도 안 흔들렸다).
⛔ 답변 형식을 계측기에 맞추지 않는다. 반대로 계측기가 형식 잡음을 견디게 만든다.
재측정은 베이스라인과 비교를 둘 다 새로 한다. 규칙을 만든 자료로 그 규칙을 통과시키지 않는다.
네 번째에 붙었다 (2026-09-01). 판정을 비율 기반으로 고친 뒤 처음부터 다시 측정했다.
베이스라인 10회 1판 안정 16 · 변동성 2hide 122건 + 부착 공백 구간 약 30초비교 10회 안정 16 전건 일치 · 게이지 표시 0건 → 통과슬랙이 사본 없이 정본 코퍼스를 직접 읽는다. default 는 466청크(정책+자기 문서)로 돌아왔고 설계 문서 1,582청크는 정본에서 온다. 사본 122건은 지운 게 아니라 내려가 있어서 되돌림은 두 줄이다.
앞선 세 번은 전부 계측 결함이었다
| 시도 | 막은 것 | 실제 원인 |
|---|---|---|
| 1 | 라벨 하나가 갈림 | 요청 모델 기본값이 범위 계약을 삼켜 컷오버가 조용히 무효였다 |
| 2 | 설계 라벨 하락 | 하니스가 한 코퍼스에 물었다 — 라이브는 두 개를 본다 |
| 3 | 라벨 하나가 갈림 | 판정 설계 — “n회 균일” 은 결정성이 아니다 |
제품이 문제였던 적은 한 번도 없다. 그리고 매번 규칙이 나를 멈춰 세웠고 나는 멈췄다 — 세 번째에는 규칙이 세 번 연속 같은 자리에서 발화한 것 자체가 신호였다.
배운 것 하나만 남긴다
사전 등록은 순서를 지켜 주지 산수를 대신해 주지 않는다. 나는 회차 수를 3→5→10 으로 올리며 매번 답변 근거를 적었는데, 그 답변 근거는 전부 p=0.5 를 가정한 것이었다. 실제로 막은 것은 p≈0.9 짜리 라벨이었고, 그건 회차를 늘려도 집합 동일성 판정을 계속 통과한다. 틀린 차원을 세 번 늘렸다.
지금 판정기는 매번 이 줄을 함께 찍는다.
⚠ 탐지 가능한 최소 변화량: 1.0 → ≤0.5. 그보다 작은 변화는 이 하니스가 못 잡는다.정밀도를 짐작하게 두지 않고 한계를 먼저 말한다.
“부분 실행과 전체 실행이 다르다” 도 철회한다 (2026-09-02). 어제 그것을 재현되는 사실로 적고 오픈 항목으로 올렸다. 파 보니 확립되지 않는다.
검색과 답변 근거 조립은 결정성이다. 같은 질의를 워밍업 0회와 8회로 돌려 상위 20 청크를 비교하니 순서까지 같은 해시였고, 조립된 프롬프트가 바이트 단위로 같았다. 갈릴 자리가 없다.
수를 상태별로 갈라 세면 이렇다.
| 전체 실행 | 부분 실행 | |
|---|---|---|
| 옛 상태 | 17/20 | 8/16 |
| 새 상태 | 10/10 | 10/10 |
옛 상태의 p=0.034 는 이상해 보여서 고른 대상을 사후에 검정한 값이라 근거가 못 된다. 그리고 같은 조건의 전체 실행끼리도 10/10 과 7/10 으로 갈렸다 — 비교하려던 잡음이 비교 대상보다 크다.
⇒ 두 배치의 분수를 눈으로 견주어 만든 발견이었다. 이번 주에 나를 세 번 멈춰 세운 것과 같은 부류다. 그때는 규칙이 잡아 줬고, 이번엔 내가 그 규칙을 안 쓰고 눈으로 봤다.
규칙 하나를 더한다 — 두 조건이 다르다고 말하기 전에 검정한다. 그리고 이상해서 고른 대상의 사후 p 값은 근거가 아니다.
34시간 동안 신호가 한 줄도 안 쌓였고 검사 1,800개가 초록이었다 (2026-09-02). 읽기 범위를 붙이면서 해소된 목록을 req.tenant 에 넣었다. 그 필드는 str 이고 search_log.tenant 는 TEXT 다. 적재가 터졌는데 record_search 는 절대 raise 안 하도록 만들어져 있어서 조용히 죽었다.
그 설계는 옳다 — 로그 쓰기가 사용자 요청을 죽이면 안 된다. 문제는 실패가 아무 데도 안 남는다는 것이다. 로그에도 안 남는다.
발견한 경위도 적어 둔다. 컷오버가 만든 오픈 항목 하나(“교차 테넌트 조회를 단일 테넌트로 기록하면 수요가 오귀속된다”)를 처리하러 스키마를 보다가, 그 테이블이 애초에 비어 있는 것을 봤다. 오귀속을 고치러 갔다가 적재가 죽어 있는 것을 찾았다.
근본 원인을 고쳤다 — 타입이 str 인 필드에 목록을 넣지 않는다. 귀속은 단일 값으로 두고 범위는 별도 칼럼에 남긴다. 그게 없으면 슬랙이 설계 코퍼스를 읽어도 행에는 정책 코퍼스만 남아, demand-pull 판단이 “설계 코퍼스는 아무도 안 본다” 로 읽힌다 — 컷오버가 자기를 정당화할 측정을 오염시키며 시작하는 셈이었다.
검사 넷을 실제 Postgres 에 걸었다. 그중 하나가 이걸 잡았을 검사다 — 행이 실제로 앉고, 개수가 하나 늘고, 범위가 남는가. 앞의 1,800개는 아무도 “쌓이는가” 를 안 물었다.
⛔ 그리고 같은 모양이 다른 곳에도 있다 — 답변 원문 보존, 피드백, 감사 기록, 계수 훅. 전부 best-effort 이고 전부 조용히 죽을 수 있다. 오픈 항목으로 올렸다.
앞 항목의 진단을 정정한다 (같은 날). “실패가 아무 데도 안 남는다. 로그에도 안 남는다” 고 적었다. 틀렸다 — search.signal.persist_failed 경고가 그때 찍혀 있었다. 내가 못 본 이유는 단순하다: 34시간 전 사건인데 나는 최근 2시간만 봤다.
조치 방법이 달라진다. 침묵이 문제였다면 “실패를 더 크게 내라” 가 답이다. 실제 문제는 경고가 남았는데 아무도 안 읽는 것이고, 그러면 답은 마지막 적재 시각을 보는 건강 신호 쪽이다. 경고를 더 키워도 읽는 사람이 없으면 같은 일이 또 난다.
그리고 스키마를 한 곳만 고쳤다가 CI 가 잡았다. 런타임 ensure_search_log() 에 ALTER 를 넣었는데, CI 의 Postgres 는 init.sql 로 스키마를 만들고 그 함수를 안 부르는 검사가 있다. 스키마는 세 곳이 같아야 한다 — init.sql · 마이그레이션 · 런타임 DDL.
⛔ 그리고 나는 빨간 검사가 남아 있는데 머지했다. 두 검사가 아직 도는 중이라 대기 루프를 빠져나왔고, 그 뒤 하나가 실패했다. 이 회차에만 두 번째다.
건강 신호를 만들자마자 그것을 오독했다 (2026-09-02). search_log 가 34시간 죽어 있던 것을 아무도 못 본 뒤, 표별 마지막 적재 시각을 보는 명령을 하나 만들었다.
첫 버전은 경과 시간만 냈다. 그 표를 보자마자 나는 search_answer_text 가 62시간째 2행인 것을 두 번째 결함으로 읽었다. 아니었다 — 그 표는 슬랙이 답을 낼 때만 쌓이고 그동안 슬랙 질문이 없었다. 정상이다.
⇒ “안 쌓임” 은 “죽음” 과 “조용함” 을 못 가른다. 경과 시간만 내는 신호는 읽는 사람을 그 오독으로 데려간다. 그래서 표마다 무엇이 쓰게 하는가를 함께 내고, 그것을 검사로 강제한다.
search_log 행 1072 · 마지막 (1.1시간 전) 검색·답변 신호 — 쓰게 하는 것: 모든 검색·답변 — 조용할 이유가 거의 없다
search_answer_text 행 2 · 마지막 (62.1시간 전) 답변 원문 보존 — 쓰게 하는 것: 슬랙이 답을 낼 때만 — 질문이 없으면 안 쌓이는 게 정상임계는 두지 않았다. 임계를 두면 그것이 곧 또 하나의 안 읽는 신호가 된다 — 이 결함의 원인이 정확히 그거였다.
그리고 같은 파일에서 dataclass 필드 순서를 두 번 틀렸다(기본값 있는 필드를 앞에). 두 번째에는 주석으로 박아 뒀다.
두 보강이 이틀 동안 꺼져 있었고, 검사는 전부 초록이었다 (2026-09-02). 컷오버가 끝난 뒤 라이브 로그를 읽다가 section_fill_failed 를 봤다. 오류 문장이 전부를 말한다:
invalid input for query argument $2: ('default', 'design_docs') (expected str, got tuple)섹션 필은 범위를 c.tenant = $n 에 그대로 묶고 있었고, 읽기 범위는 2026-08-31 부터 튜플이다. asyncpg 는 튜플을 TEXT 인자로 받지 않는다. 그리고 부르는 쪽은 그 예외를 삼킨다 — 보강 실패가 검색을 죽이면 안 된다. 그 설계는 옳다. 결과는 옳지 않다: 섹션 필과 페어 확장이 모든 HTTP 답변 요청에서 조용히 꺼졌다.
⛔ 원소가 하나여도 튜플이다. 그래서 이것은 컷오버한 배포만의 문제가 아니었다 — 범위 목록을 쓰지 않는 단일 테넌트 배포까지 같은 날 같이 꺼졌다. 사람이 못 보는 결함의 범위는 대개 내가 처음 짐작한 것보다 넓다.
왜 검사가 초록이었나가 이 항목의 값이다. 섹션 필에는 진짜 Postgres 를 치는 검사가 여덟 개 있고, 그중 하나는 채운 절이 LLM 프롬프트에 실제로 들어갔는가까지 단언한다. 그 여덟이 전부 테넌트를 문자열로 넘겼다. 페어 확장 검사 다섯은 DB 를 아예 안 친다 — 파일 이름으로 짝을 잇는 순수 함수만 본다. 와이어링이 통째로 죽어도 열세 검사 중 하나도 안 움직인다.
그리고 그 자리에 실제 구현과 불일치하는 주석이 기재되어 있었습니다. 범위 술어를 한 함수로 모아 둔 모듈의 첫 문단이 “새 읽기 경로가 생기면 이것을 부르면 되고, 안 부르면 검사가 잡는다” 라고 명시되어 있었으나, 실제 해당 누락을 감지하는 검증 로직은 부재했습니다. 해당 파일의 단위 테스트는 함수 자체의 반환값만 검증할 뿐 호출 호출부의 호출 빈도를 집계하지 않았습니다.
고친 것 셋:
- 두 질의가 범위 술어를 쓴다. 원소 하나면
= $n, 여럿이면= ANY($n)— SQL 이 서로 다르므로 둘 다 돌려 봐야 한다. - 답변 경로를 세 범위 모양(문자열 · 원소 하나 튜플 · 둘)으로 한 번씩 도는 검사. 단언은 함수 반환값이 아니라 아무 보강도 조용히 죽지 않았는가에 건다 — 이름을 세지 않으므로 앞으로 붙을 보강도 같은 회귀 검사에 든다. 일부러 옛 코드로 되돌려 확인했다: 9개 중 6개가 빨간불이 되고, 초록으로 남는 셋이 정확히 문자열 모양이다.
- 거짓 문장을 고쳤다. 이제 그 자리는 무엇이 실제로 지키는지를 이름으로 가리킨다.
⛔ 닫은 오픈 항목 하나를 되살렸다. 09-01 에 “짝 확장이 설계 코퍼스의 specs/·plans/ 를 본다” 며 항목을 닫았다. 사람 API 표면에서는 한 번도 안 돌았다 — 라이브 로그에 pair_expansion_failed 만 있고 성공 기록이 0건이다. 닫음의 답변 근거가 CLI 한 경로였고, 그 경로만 테넌트를 문자열로 넘긴다.
범위를 넓혀 놓고, 그것이 값을 냈는지 볼 필드가 없었다 (2026-09-02). 컷오버로 슬랙이 설계 코퍼스를 직접 읽게 됐다. 그런데 기록에는 “이 답이 실제로 어느 코퍼스에 기댔는가” 를 말하는 필드가 없었다:
| 필드 | 무엇을 말하나 | 무엇을 못 말하나 |
|---|---|---|
search_log.tenant | 누구에게 귀속되는가(단일 값) | 답변 근거가 어디서 왔는가 |
search_log.read_scope | 무엇을 읽을 수 있었는가 | 실제로 무엇을 읽었는가 |
⇒ 범위를 넓혀 놓고 답변 근거가 여전히 한쪽에서만 오는 상태와 고르게 오는 상태가 기록에서 똑같아 보인다. 컷오버가 값을 냈는지 그 두 칸으로는 못 가른다.
evidence_tenants 를 더했다(마이그레이션 038) — 질문당 테넌트별 청크 수다.
⭐ 히트가 아니라 패킷에서 센다. 이게 이 항목의 값이다. 라이브 첫 행이 n_snippets=10 인데 default:18 이다 — 채운 절·페어 문서·정정 확인 패스는 랭킹을 거치지 않고 답변 근거에 들어오기 때문이다. 히트로 셌으면 답변이 기댄 답변 근거의 44%를 빠뜨린 채 “이 답은 무엇에 기댔나” 를 말했을 것이고, 그 셋이 바로 컷오버가 설계 코퍼스에서 끌어오는 것들이다.
비율이 아니라 개수를 남긴다. 비율은 분모를 지운다 — 1.00 이 청크 하나인지 스무 개인지 같아 보인다. 개수에서 비율은 언제든 나오고 그 반대는 안 된다.
⛔ 그리고 만들자마자 첫 버전이 행 하나로 100.0% 를 찍었다. 질문 하나짜리 비율인데 인용될 만해 보였다 — 이번 주에 같은 유형으로 두 번 데였다(적재 건강 신호의 62시간 오독 · 두 배치의 분수를 눈으로 견준 것). 그래서 질문 10건 미만에서는 비율을 아예 안 낸다. 개수는 그대로 낸다. 이 리포가 이미 정해 둔 규율이다.
임계값은 두지 않았다 — §5.3 이 “첫 회차는 관측이고 임계는 그 분포를 보고 정한다” 고 적어 둔 그대로다.
⚠ 그리고 그 SPEC 은 §5.4 에서 “칼럼·마이그레이션 번호는 구현 시 정하고 여기 기록한다” 고 적어 뒀는데 그럴 수 없다. 승인 본문은 승인 시점 바이트로 얼어 있어 손대면 스탬프가 깨진다(그 규칙 자체가 이 리포가 각주 하나로 master 를 15연속 빨간불로 만들고 배운 것이다). 번호는 마이그레이션 파일과 오픈 항목 목록에 있고 SPEC 에는 없다 — 서식이 지킬 수 없는 약속을 하고 있다.
고치러 갔더니 고칠 병이 없었다 — 두 번째다 (2026-09-02). 오픈 항목 하나가 이렇게 적혀 있었다: “라벨 A-3·A-5 는 1판 통과·2판 실패인데, 답이 틀려서가 아니라 문서 값과 코드 값을 나란히 내면서 결론 자리에 ‘문서와 코드가 다르다’ 를 적기 때문이다.” 후보 조치 방법까지 달려 있었다 — “불일치를 밝힌 답변은 값이 표 안에 있어도 통과.”
착수하면서 규칙 대신 실물을 먼저 읽었다. 셋 다 달랐다.
① 그 증상은 이미 없다. 최종 두 실험군(각 10회)에서 A-3·A-5 는 2버전 10/10 · 10/10 이다. 갈리는 것은 다른 둘이다(A-1 9/10, A-9 7/10). 저장된 답변에 채점기를 다시 걸어 자리까지 봤다 — 확정 문장이 「절 머리」에 앉아 통과한다. 2026-08-31 의 절 머리 개정이 정확히 그 모양을 고쳤고, 오픈 항목은 그 개정과 같은 날 적혀 해소된 뒤에도 지워지지 않았다.
② 후보 조치 방법은 사전 등록을 뒤집는다. “표 안에 있어도 통과” 는 이 하니스가 명시적으로 하지 말라고 적어 둔 것이다 — 표를 받아들이면 값을 표에 적어 놓고 결론에서 물러선 답변이 같이 통과한다. 그 쌍을 가르는 것이 2버전의 존재 이유이고, 대조군이 검사에 박혀 있다. 조치 방법을 실행했다면 그 대조군이 빨간불이 됐을 것이다.
③ 진짜 잔여는 읽을 수가 없다. A-1·A-9 가 왜 갈렸는지는 아무도 모른다 — 그 스무 번의 실행이 요약만 남기고 답변 원문을 버렸기 때문이다. 답변 백엔드에 temperature·seed 가 없으니 같은 답변을 다시 만들 수도 없다. 이 리포는 같은 일로 이미 3시간을 태우고 “채점기 리포트는 답변 원문 사이드카를 남긴다” 고 적어 뒀는데, 그 규율이 옵션 하나 뒤에 있었고 판정 실행이 그 옵션을 안 켰다.
그래서 바꾼 것은 계측 와이어링뿐이고 채점 규칙은 한 글자도 안 고쳤다: 로그 파일을 남기는 실행은 원문 사이드카도 기본으로 남긴다(끄려면 명시해야 한다), 그리고 판정 스크립트가 2버전이 갈린 라벨의 회차 이름을 낸다 — 숫자에서 원문으로 가는 길이다.
⇒ 읽기 전에 규칙을 고치는 것이 그 오픈 항목이 하려던 일이었다. 규칙을 안 고쳤으므로 사전 등록할 것도 없다.
⚠ 이 정정을 쓰면서 나도 한 번 틀렸다. 처음엔 판정 파일 65개를 통째로 합산해 “2판이 15개 라벨 전부에서 흔들린다” 고 읽었다. 그 파일들은 실험군 열 개에 걸쳐 있고 개정 전 채점기로 돈 회차까지 섞여 있다. 실험군별로 갈라 세니 흔들리는 것은 둘이었다. 파일 이름이 같은 접두사를 쓴다는 것은 같은 조건이라는 뜻이 아니다.
오픈 항목이 몇 건인지 세는 파일이, 그 수를 손으로 미러링하고 있었다 (2026-09-02). 하루 동안 항목을 넷 닫고 셋 열면서 머리말의 수를 증감시켰다 — 23 → 24 → 26. 그 수를 보고에 그대로 썼다.
사용자가 물었다: “26건이나 된다고? 그게 뭔데?”
세어 봤다. 21이었다. 사람 쪽도 15가 아니라 14였다. 원인은 단순하다 — 닫을 때 뺀 적이 없다. 열 때만 더했다.
이 파일이 존재하는 이유가 정확히 그것이다. 머리말 첫 문단이 이렇게 시작한다: “미결이 SPEC·ADR·메모리·대화에 흩어져 있어서 줄고 있는지 늘고 있는지 아무도 몰랐다. 여기 모아 세고, 보고할 때마다 이 수를 말한다.” 그 수 자체를 손으로 미러링하고 있었다. 이 리포는 손으로 미러링하는 목록이 전부 부패원이라고 이미 두 번 적어 뒀다.
⇒ scripts/check_open_counts.py 를 CI에 걸었다. 취소선 그어진 줄은 안 세고(그게 이 파일의 규칙이다), 요약 표와 절 제목 두 자리를 같이 본다 — 한쪽만 고치는 것이 정확히 이번 사고의 모양이다. 검사는 양방향이다: 많이 적어도, 적게 적어도 빨간불이다.
일부러 어긋나게 해서 확인했다. 그리고 검사기 첫 버전이 실패를 보고하려는 바로 그 순간 UnicodeEncodeError 로 죽었다 — 콘솔 코드페이지가 cp949였다. 이 리포의 훅이 같은 이유로 같은 도우미를 이미 갖고 있다. 검사기는 죽으면 아무것도 안 말한다.
⚠ 잡은 것은 CI가 아니라 사람의 질문 하나다. 숫자가 그럴듯해 보이면 아무도 세어 보지 않는다.
“문서가 낡아서인가” 를 물었더니, 그 질문을 물을 수 없는 상태였다 (2026-09-02). 답변 품질이 왜 이 모양인지 파고들다가 가장 그럴듯한 가설에 닿았다 — 노션 정책 문서가 낡았나. 측정했다.
문서 나이: 126건 전부 "3개월 이내"이 수는 아무 말도 안 한다. documents.updated_at 은 우리 적재 시각이다. 하마터면 “문서는 안 낡았다” 고 보고할 뻔했는데, 그 수가 말한 것은 우리가 8월에 적재했다 뿐이었다.
신선도 판정 모듈이 자기 docstring 에 이미 적어 뒀다:
“한계:
updated_at은 적재 시각이라, 내용 그대로 재동기화된 문서는 신선해 보인다. 더 정확한 신호(origin_last_edited)는 후속.”
⇒ 재적재할 때마다 모든 문서가 새것이 된다. 구조상 “낡음” 을 절대 못 말하는 신호다.
⭐ 그리고 값은 이미 오고 있었다. 노션 커넥터가 last_edited_time 을 받아 origin_last_edited 로 frontmatter 에 싣는다. 앉을 자리가 없었을 뿐이고, 그래서 코드 전체에서 그 이름이 딱 두 곳에 나온다 — 싣는 곳과, 후속이라고 적어 둔 주석. 이 리포가 반복해서 겪은 모양이다: 신호는 만들어지는데 읽을 자리가 없다.
필드를 만들었다(마이그레이션 039). 그리고 판정은 안 바꿨다 — 경고를 바꾸는 것은 사용자가 보는 것을 바꾸는 일이고, 옛 문서가 무더기로 경고를 다는 것을 감당할지부터 정해야 한다. 여기서 한 것은 질문을 물을 수 있게 만드는 것까지다.
읽는 쪽도 같이 냈다. 두 나이를 나란히 낸다 — 하나만 내면 읽는 사람이 방금 나처럼 오독한다:
[default] 활성 문서 126건 — 두 나이는 다른 것이다 원본 수정 우리 적재 3개월 이내 0 126 미상 126 0⚠ 미상 은 상태 미확인이지 신규 아티팩트가 아닙니다. 그 구분을 명확히 하지 않으면 본 영역 역시 앞서 제거한 부정확한 기술을 그대로 답습하게 됩니다.
필드를 만들고 적재를 돌렸는데 한 건도 안 채워졌다 (2026-09-02, 같은 날). 앞 항목에서 문서 자신의 수정 시각을 저장하게 했다. 적재를 돌리고 확인했더니 126건 전부 그대로 미상 이었다.
값을 버린 자리는 build_csf 였다 — 커넥터가 받은 frontmatter 에서 다음 단계로 넘기는 dict 를 만드는 함수다. 거기에 그 필드가 없었다.
⭐ 그 함수의 주석 둘이 앞선 두 번의 같은 사고를 이미 적어 두고 있었다. 하나는 제목(노션 페이지 Index 가 코퍼스에 딴 이름으로 들어갔다), 하나는 그림 수(재적재마다 0 으로 덮였다). 이 접합부에서 값이 사라진 것이 세 번째다.
⛔ 그리고 내 검사는 그것을 못 잡았다. _save_document 를 직접 불러서 통과했기 때문이다. 실제 경로는 커넥터 → build_csf → 임시 마크다운 → collector → pipeline 이고, 끊긴 곳은 그 사슬의 첫 칸이었다. 이 리포가 이미 적어 둔 실패다 — “생산자의 dict 를 검사했다.” 이제 사슬을 통과시키는 검사가 있고, 일부러 되돌려 빨간불을 확인했다.
두 번째 벽도 있었다. 본문이 안 바뀐 문서는 콘텐츠 해시 dedup 이 저장 자체를 건너뛴다. 그래서 새 메타데이터 필드는 본문이 바뀌는 문서에만 채워진다 — 편향된 표본이다. 백필하려면 파괴적인 --reconcile(문서를 지울 수 있다)을 같이 켜야 했다. 메타데이터 한 필드 때문에 그 경로를 여는 것은 거래가 안 맞아서, --force 단독을 “본문이 같아도 다시 저장한다” 로 열었다. 지우지 않는다.
그래서 문서는 낡았나 — 그렇다
[default] 활성 문서 126건 원본 수정 우리 적재 3개월 이내 14 126 6~12개월 8 0 1년 이상 90 0우리가 갖고 있던 신호는 정반대를 말하고 있었다. 노션 코퍼스만 갈라 세면 이렇다 — 본문을 가진 문서 19건 중 9건이 1년 이상(최신 2025-07-20), 8건이 3~12개월(최신 2025-11-02, 글자의 대부분), 3개월 이내는 2건이다. 범위는 2023-12 ~ 2026-08.
⇒ 코드는 움직였는데 정책 본문은 대체로 열 달 넘게 안 고쳐졌다. 앞서 관측한 문서↔코드 불일치(정원 50 vs 200 …)가 정확히 그 결과다. 문서를 고치는 것은 이 리포의 일이 아니다 — 불일치를 정확히 전달하는 것까지가 여기 몫이다.
내가 라이브 코퍼스를 깨뜨렸다 (2026-09-02). 앞 항목의 메타데이터 필드를 백필하려고 ingest-notion --force 를 돌렸다. 끝났다는 출력은 초록이었다. 그리고 코퍼스는 이렇게 됐다:
청크 466 → 385machine_read 81 → 0그림에서 읽은 텍스트가 전부 사라졌다. 이 조직 문서는 정책 대부분이 그림 안에 있어서, 라이브 답변이 실제로 나빠졌다 — 같은 질문이 값을 답하다가 “확인할 수 없습니다” 로 기권했다.
원인은 게이트 하나다. NEXUS_VISION 은 꺼짐이 기본이고(그림이 공급자로 나가는 것은 코퍼스를 가진 배포가 하는 판단), 꺼져 있으면 적재가 그림 플레이스홀더를 빈 이미지로 지운다. 나는 그 게이트를 확인하지 않고 재적재를 돌렸다.
⛔ 그리고 그 재적재를 가능하게 한 것이 같은 날 내가 넣은 변경이다. 백필하려면 파괴적인 --reconcile 을 켜야 했던 것이 싫어서 --force 단독을 열었다 — 파괴적 경로를 피하려고 연 문으로 다른 파괴를 했다.
복구는 됐다. 기계 판독 텍스트가 vision_extractions 에 살아 있었고 캐시가 이미지 해시로 맞아서, NEXUS_VISION=on 재적재로 466/81 로 정확히 돌아왔다(공급자 호출 0 · 지출 0). 하지만 그건 그 사실을 아는 사람만 할 수 있는 복구다.
그래서 무엇이 없었나 — 가드 검사
ADR-0010 §4 는 마커가 벗겨져 추출 텍스트가 작성자 텍스트로 세탁되는 것을 “추출 안 하느니만 못하다” 고 적었다. 이것은 그보다 나쁘다 — 세탁이 아니라 삭제다. 그런데 그 경로에는 경고도, 확인도, 검사도 없었다.
조용히 지우는 것이 옳은 경우가 없다. 그래서 거부한다: 기계 판독 꺼짐 + 지울 것 있음 + 명시적 선언 없음 이면 종료코드 2 로 멈춘다. 지우는 것이 의도라면 NEXUS_VISION_ALLOW_DROP=1 로 선언해야 한다. 이 리포가 이미 갖고 있는 규율의 모양이다 — 테스트 DB 는 스스로 버려도 된다고 선언해야 한다.
라이브에서 같은 명령이 실제로 멈추는 것을 확인했고, 판정식을 적재 경로와 같은 함수로 두었다(사본을 두면 가드 검사는 통과시키는데 적재는 지우는 조합이 생긴다).
⚠ 그리고 이 항목을 오픈 항목 목록에 적으면서 오픈 항목 수를 또 틀렸다 — 닫힌 항목을 더해 23 으로 올렸다. 몇 시간 전에 만든 검사가 그 자리에서 잡았다.
“해석기가 절반을 못 읽는다” 를 세어 봤더니 병목이 다른 데 있었다 (2026-09-02). 답변이 문서 값만 말하고 코드 값을 못 붙이는 문제를 고치려고, 먼저 어느 모양이 몇 개인지 셌다(테스트 제외 자바 899파일):
| 모양 | 개수 | 읽었나 |
|---|---|---|
static final 숫자 상수 | 66 | ✅ |
| 이름 있는 어노테이션 인자 | 59 | ✅ |
위치 인자 @Min(1) | 29 | ❌ → 이번에 열었다 |
가드절 if (… > N) | 18 | ❌ (열지 않는다) |
@Scheduled(cron=) | 7 | ❌ |
| 상수를 참조하는 인자 | 1 | ❌ |
⭐ 읽을 수 있는 자리가 125개인데 claim 은 10개였다. 오픈 항목은 “해석기가 실물의 절반쯤을 못 읽는다” 라고 적고 있었는데, 세어 보니 해석기보다 안 심은 쪽이 훨씬 크다. claims.yaml 은 사람이 손으로 적고, 열 줄이 적혀 있었다. 그리고 그 열 건은 전부 unverified — 검증 루프가 한 번도 안 돌았다.
위치 인자 — 열되, 자바 규칙이 정한 자리에만
@Min(1) 의 1 은 value 원소다. 원소가 하나인 어노테이션은 value= 를 생략할 수 있다는 자바 규칙이 그 자리를 못박아 두었다. 그래서 value 를 묻는 claim 에만 채운다 — @Size.max 를 묻는 claim 이 @Min(1) 로 채워지면 답변의 “코드는 …” 자리에 남의 값이 조용히 앉는다. 값이 틀린 것보다 나쁘다. 틀린 채로 확신하기 때문이다. 그 성질을 검사로 박았다.
⭐ 그리고 열어 보니 한 겹이 더 있었다. 위치 인자 29개 중 필드에 붙은 것이 0개였다 — 전부 메서드·레코드 인자다. 해석기는 필드 선언만 보고 있었으므로, 인자를 읽는 것만으로는 여전히 한 개도 안 읽혔을 것이다. 선언의 끝을 ;= 에서 ;=,) 로 넓혔다.
넓히면 같은 이름이 여러 자리에서 잡힐 수 있다. 그 위험은 이미 막혀 있었다 — 값이 갈리면 해석기가 답을 고르지 않고 “값이 갈린다” 로 거부한다. 위험한 자리는 넓히는 쪽이 아니라 고르는 쪽이고, 거기는 손대지 않았다.
라이브 확인: 실물 코드에서 @Max.value → 60, @Min.value → 1. 디제잉 제한 시간의 코드 쪽 경계이고, 문서는 같은 값을 “3분 이상” 이라고 적고 있다.
⛔ 가드절 18개는 열지 않았다. if (activeCrewCount > 49) 를 50 으로 읽으려면 부등호·경계·변수 의미를 전부 맞혀야 한다. 그것은 값 읽기가 아니라 코드 이해이고, 틀리면 확신하는 문장으로 나간다. 소유자가 문서를 기각한 정원 50 이 하필 거기 있어서 탐나지만, 탐난다는 것이 근거는 아니다.
외부 평가가 가장 큰 결함을 찾았다 — 웹 채팅만 다른 경로를 타고 있었다 (2026-09-02). 외부 평가자(다른 모델)가 Nexus 를 평가하고 결과서를 냈다. 등급 상 두 건 중 하나가 이것이다.
reconcile.py 의 packet_for_answer docstring 이 이렇게 적어 두고 있었다:
“답변용 근거 패킷은 이 함수 하나로 만든다. 답변 경로가 셋이다 — 각자
assemble_packet을 부르면 보강을 한 곳에만 붙이는 배선이 가능해지고 그 조합은 검사가 초록인 채로 프로덕션에서 조용히 틀린다. 2026-08-29 에 내가 그대로 반복했고, 그래서 여기로 모은다.”
그 문장을 지키는 검사가 없었다. 네 API 표면 중 /search/answer/stream 하나가 assemble_packet 을 직접 불렀고, 그래서 정정 확인 패스·페어 확장·코드 값이 그 경로에서만 빠졌다. 그리고 그 경로가 웹 채팅이 타는 경로다(web/js/api.js).
평가자의 실측(정책 8질의):
qid /search/answer /stream 차이p04 23 20 −3p05 21 18 −3p07 19 13 −6 (32%)p08 18 15 −3⇒ 같은 질문에 CLI·A2A·비스트리밍은 보강을 받고 웹 사용자만 못 받았다.
고친 것
packet_for_answer 로 돌렸다. 곁가지로 이 경로만 effective_scope 를 쓰고 있어서 컷오버가 연 교차 코퍼스를 못 읽고 범위 밖 감사 이벤트도 안 남기고 있었다 — 그것도 같이 닫았다.
재측정: 8질의 전부 차이 0, 값이 비스트리밍 열과 정확히 같다.
검사가 없던 이유
API 표면을 세는 검사가 없었다. 새로 만들면서 두 가지를 배웠다:
- 중첩 함수까지 걸어야 한다. 스트리밍 핸들러의 패킷 조립은
event_stream()안에 있어서, 바깥 함수만 보면 결함이 있는 버전에서도 초록이다. 일부러 되돌려 정확히 그 API 표면만 빨간불이 되는 것을 확인했다. - ⛔ 기존 검사 하나가 우회 함수를 스텁하고 있었다.
test_answer_payload_contract.py가assemble_packet을 no-op 으로 바꾸고 있었는데, 그 시험이 치는/search/answer는 그 함수를 부른 적이 없다. 죽은 줄이었다. 지웠다.
⚠ 그리고 고치다 하나 더 나왔다 — /search(검색 전용 엔드포인트)도 여전히 effective_scope 다. 답변 경로 넷은 이제 전부 범위를 받는데 검색만 안 받는다. 사람이 직접 보는 목록이라 별도 확인이 필요해 항목으로 올렸다.
읽을 권한 없는 문서의 제목이 프롬프트로 나갈 수 있었다 (2026-09-02). 외부 평가의 두 번째 발견(F3). nexus/CLAUDE.md 는 이렇게 적어 두고 있다 — “모든 SELECT 에 정책 필터를 건다. 예외 없음.” 네 절은 테넌트·등급·격리·상태다.
그 예외가 두 자리에 있었다. 대체 문서를 잇는 조인:
LEFT JOIN documents s ON s.rid = d.superseded_by AND s.tenant = d.tenants 에 등급도 격리도 상태도 없다. 그리고 그 제목은 describe() 를 지나 프롬프트에 들어간다 — 그 함수 자신이 “프롬프트에 들어가는 한 줄” 이라고 적는다. 문서 목록 API 도 같은 조인으로 같은 필드를 낸다.
라이브 누출은 0건이었다. 그런데 재료는 다 있다:
대체 관계 있는 문서 121건RESTRICTED 문서 17건격리 문서 4건누출 조합 0건 ← 아직 안 겹쳤을 뿐잠복이지 안전이 아니다. 적재 한 번이면 겹친다.
고치면서 갈린 것 — 이름과 사실
필터를 걸면 대체 문서의 제목이 빈다. 그때 “대체됐다” 는 부채까지 같이 사라지면, 읽는 사람은 스테일한 답변 근거를 스테일한 줄 모르고 읽는다.
⇒ 이름은 안 주고 사실은 준다. 은퇴했다는 것은 읽는 사람이 이미 보고 있는 문서에 대한 사실이고, 등급이 지키는 것은 대체한 쪽의 내용과 신원이다. 그래서 superseded 를 제목과 갈라 두었다.
그리고 등급은 선택 인자가 아니다. 빈 등급을 “필터 없음” 으로 읽으면 등급 없이 부르는 자리가 곧 우회로가 된다 — 빈 값이면 조회를 건너뛴다.
기존 검사가 못 잡은 이유
test_documents_api.py는 제목이 나오는 것을 단언한다. 등급 밖일 때 안 나오는 것을 단언하는 검사는 없었다test_doc_debt.py는 DB 를 안 치고 객체를 손으로 만든다 — 그래서 이 SQL 을 지나간다
새 검사는 행을 심는다. 상위 등급·격리·비활성 세 경우를 각각 만들고, 제목이 프롬프트 줄에 없는 것과 사실은 남는 것을 같이 단언한다. 대조군 둘도 뒀다 — 읽을 수 있는 대체 문서는 여전히 이름이 나오고, 등급을 올리면 보인다(그래야 위 단언이 “등급 때문” 임이 증명된다).
등급 필터만 꺼 보니 아홉 중 정확히 그 한 검사가 빨간불이 됐다.
주제 이탈 — 평가자의 가설은 맞았고, 이유는 틀렸다 (2026-09-02). 외부 평가의 나머지 「상」(F8): 코퍼스 밖 질문에 어휘가 겹치면 확신 있게 답한다. 승인 게이트의 서명 주체를 물었더니 쿠버네티스 인증서 승인을 세 회차 모두 정확히 답했다.
⛔ 이 리포가 코드로 판정하는 두 차원이 구조적으로 눈이 없다. 인용은 실재하고 숫자도 실재한다. 추측도 아니다 — 실재하는 답변 근거로 다른 질문에 답한 것이다. 잡은 유일한 장치가 5건짜리 기권 대조군이었다.
평가자는 확인하지 못한 가설을 남겼다 — “어휘가 겹치므로 BM25 가 높아 and 규칙을 통과했을 것”. 측정했다:
| 질의 | 거리 | BM25 | weak |
|---|---|---|---|
| 어휘 겹침·코퍼스 밖 | 0.4694 | 0.295 | False |
| 겹침 없음·코퍼스 밖 | 0.5569 | 0.000 | True |
| 코퍼스 안 | 0.3211 | 1.038 | False |
BM25 는 이미 임계값 아래였다(0.295 < 1.5). 규칙을 비켜간 것은 벡터 쪽이다 — 거리 0.4694 가 임계값 0.48 을 0.011 차이로 지나갔다.
⭐ 그 자리는 임계값의 저자가 미리 경고해 둔 곳이다. confidence.py 가 이렇게 적고 있다:
⚠ 임계값은 여전히 가설이다. 질문 17개가 전부 내가 지었고, 중간 구간의 최대 거리(0.470)와 임계값(0.48) 사이가 0.010 밖에 안 된다 … 실사용 질문 하나가 중간 구간에서 발동하면 그때 0.51 근처로 옮긴다.
그런데 옮기지 않는다 — 두 가지 때문에
① 관측된 사례가 실사용이 아니다. 실사용 질문 넷으로는 임계값이 옳게 돈다(밖 0.614·0.589 발동 · 안 0.255·0.310 미발동). 저자가 “측정하기 전에 표본이 지어낸 것인지 실사용인지 밝힌다” 를 사전 등록해 뒀고, 유일한 실패 사례는 지어낸 라벨이며 다른 코퍼스다.
② 그리고 트리거가 이미 울렸는데 그것을 읽을 수가 없다. search_log 에 중간 구간 행이 8건(최대 0.4765) 쌓여 있다. 하지만 그것이 실사용인지 내 탐침인지 가를 수 없다 — 질의 텍스트 보존의 키가 일부러 조인 불가하게 설계돼 있기 때문이다(텍스트 옆에 식별 정보가 앉지 않게 한 그 설계). 저자의 트리거는 실사용 질문을 요구하는데, 그 판정을 지금은 못 한다.
⛔ 그리고 임계값을 옮겨도 이 결함을 다 막지 못한다. weak 는 답을 막지 않고 응답 계약만 바꾼다 — 같은 파일이 그렇게 적어 두었다. 임계값은 부분 대응이고, 주제 이탈 자체를 판정하는 차원은 여전히 없다.
멀티홉 0/3 을 결정론적으로 갈랐다 — 둘은 조립 실패, 하나는 생성 실패 (2026-09-02). 외부 평가가 실제 코퍼스에서 멀티홉 0/3 을 냈고, §11 이 “정답 청크는 상위 10 에 잘 들어와 있다” 까지 좁혀 놨다. 남은 물음은 하나였다 — 요구된 사실이 프롬프트에 들어가기는 하는가.
LLM 을 부르지 않고 확인했다(지출 0, 잡음 0):
| qid | 요구된 사실 | 프롬프트에 |
|---|---|---|
| m01 | 최대 10개 | 없다 |
| m02 | 최대 10개 | 없다 |
| m03 | 둘 다 | 있다 |
⇒ 조립 실패 2 · 생성 실패 1. 그리고 이 판정이 §9 의 sufficiency=insufficient 2건과 독립적으로 일치한다 — 심판 모델과 문자열 대조가 같은 둘을 지목했다.
메커니즘
빠진 사실은 플레이리스트 정책 의 한 절에 있고, 그 문서는 히트에 들어 있다. 섹션 필이 바로 이런 자리를 위해 있는데 안 돌았다.
필의 트리거는 “문서가 diversity_per_doc_cap 을 채웠는가” 다. 그 문서가 상한만큼 실렸다는 것은 다양성 규칙이 그 문서를 깎았다는 뜻이고, 곧 검색이 거기 몰표를 줬다는 신호다. 합리적인 트리거다 — 단일 문서 질문에 대해서만.
m01 문서 다섯이 상위 10 을 나눠 가짐 → 포화 0건 (파티목록 4 · Entity 2 · 플레이리스트정책 2 · 나머지 1·1)m02 그 문서가 4/5 — 하나 모자란다⭐ 필은 집중에 트리거되는데, 멀티홉은 정의상 분산이다. 문서 다섯으로 흩어지면 아무것도 포화하지 않고, 한 끗 차이로 못 채우는 경우(m02)도 생긴다. 둘째 필 경로(히트가 앉은 절을 완성하는 것)도 못 잡는다 — 사실이 다른 절에 있기 때문이다.
이것이 단일 값 질문은 잘 나오고 멀티홉만 0/3 인 이유다. 단일 값은 한 문서에 몰리고, 멀티홉은 그러지 않는다.
고치지 않았다
후보 설정값은 보인다 — diversity_per_doc_cap · FILL_TOP_HITS · 멀티홉 전용 트리거. 고르지 않는다. 이 리포는 기법 추가로 7전 7패했고 오른 것은 전부 결함 제거였다. 그리고 넓히는 데는 값이 있다 — 히트 열 개의 절을 전부 완성하면 답변 근거 문자수가 +102% 다(2026-08-26 실측).
메커니즘이 이름을 얻었으니, 다음은 사전 등록한 측정이다.
“유일한 곳” 이라고 적어 두고 두 곳에 있었다 (2026-09-02). 외부 평가 F1. auth/clearance.py 의 첫 줄이 “the single source of truth” 이고, 같은 파일이 “파이썬 쪽에서 순서가 사는 유일한 곳이라 SQL enum 과의 동등성을 검사로 단언할 수 있다” 고 적는다. nexus/CLAUDE.md 의 심 맵도 “정본 하나. 사본을 만들면 두 답이 생긴다” 라고 말한다.
models/resource.py 에 두 번째 순서표가 있었다. 그리고 그것으로 접근 통제 함수까지 구현돼 있었으며, 그 docstring 은 “모든 검색/조회에 이 함수를 적용한다” 고 지시했다 — 심 맵과 정반대다.
프로덕션 호출자는 0이었다. 실제 통제는 SQL 의 네 절이 한다. 그래서 활성 결함이 아니라 잠재였는데, 위험의 모양이 분명하다 — 정본에는 SQL enum 동등성 검사가 붙어 있고 사본에는 없다. enum 에 등급이 하나 추가되면 정본은 빨간불이 되고 사본은 조용히 뒤처진다.
함께 있던 base_filter_sql() 도 지웠다. psycopg 스타일(%(tenant)s)인데 이 코드베이스는 asyncpg 이고, claims/repository.py 가 이미 “미사용” 이라 적어 두고 있었다. 쓸 수 없는 함수가 “모든 SELECT 에 적용할 공통 절, 예외 없음” 이라고 주장하고 있었다.
고치지 않고 지웠다
이 리포의 규율이 그것이다 — 사본은 고치지 말고 없애라. 등급 목록을 두 번 적었다가 곧바로 갈린 전례가 있다(사본엔 CONFIDENTIAL, DB enum 엔 없음).
test_crm.py 의 접근 통제 시험 7건도 같이 걷어냈다. 그 시험들의 대상이 사본이었다 — 아무도 안 부르는 순수 함수를 시험하고 있었고, 실제 통제는 진짜 DB 를 치는 검사들이 지킨다.
그리고 주장을 검사로 만들었다
지우는 것만으로는 다음 사람이 또 적는 것을 못 막는다. AST 로 딕셔너리 리터럴의 키 집합을 보아 순서표가 정확히 한 곳인지 단언한다.
⛔ 정규식이 아니라 AST 인 이유를 같이 적었다. 이 리포는 소스 문자열 검사에 데인 적이 있다(“문자열이 있다는 것은 그 코드가 돌았다는 뜻이 아니다”). 그런데 여기서 지켜야 할 성질은 텍스트 그 자체다 — 돌든 안 돌든 두 번째 사본이 존재하면 갈릴 수 있다. 주석이나 문자열 속 낱말에 안 걸리고 따옴표·줄바꿈 모양이 달라도 잡히도록 AST 로 본다.
사본을 일부러 되살려 빨간불을 확인했다.
스테일 주장을 한 번에 걷어냈다 — 그리고 README 에도 계수기를 달았다 (2026-09-02). 외부 평가가 「선언-코드 대조」를 주 산출물로 삼았고, 드리프트 넷을 이름과 함께 냈다. 전부 반영했다.
| 주장 | 실제 | 처리 |
|---|---|---|
이음매 지도: “네 표면이 assemble_packet 으로 모인다” | 스트리밍만 우회 | 모이는 자리를 packet_for_answer 로 고치고, 표면을 세는 검사를 가리키게 했다 |
hybrid.py: “BM25 + Vector + Graph 3-way 융합” | 그래프는 융합에 안 들어간다 | nexus/CLAUDE.md 는 이미 정정했는데 이 파일만 그대로였다 |
ROADMAP: “RAG + GraphRAG” | 문서 엔티티 추출은 내리기로 확정 | 유지 대상인 OTel 이중 그래프와 문구를 갈랐다 |
README: 약 1,900 test functions · 49 SPECs | 2,585 · 52 | 고치고 계수기를 CI 에 걸었다 |
README 에만 보호가 없었다
평가자의 지적이 정확했다 — 이 리포는 OPEN.md 의 오픈 항목 수를 손으로 증감시키다 틀렸고(같은 날 아침, 26 이라고 보고한 것이 실제로는 21), 그래서 check_open_counts.py 를 CI 에 걸었다. 같은 보호가 README 에는 없었다.
이제 check_readme_counts.py 가 네 수(테스트 함수·CI 잡·ADR·SPEC)를 매 푸시마다 대조한다. 세는 규칙은 검사기 한 곳에만 두고 README 는 값만 적는다 — 둘 다 세면 사본이 둘이 되고, 그것이 애초의 병이다.
⚠ 그리고 “열세 건의 결함 기록” 이라는 손 계수는 수를 아예 뺐다. 로그 항목 수는 정의가 애매해서(헤딩인가, 굵은 문단인가) 계수기를 달면 그 애매함을 코드에 박게 된다. 셀 수 없는 것은 안 센다.
⭐ 그리고 그 계수기가 자기 PR 에서 나를 잡았다. 로컬은 2,585 로 초록이었는데 CI 가 2,592 로 빨간불이 됐다 — git grep 이 추적된 파일만 세기 때문이다. 방금 만든 시험 파일이 아직 커밋 전이라 로컬에서만 빠졌다. --untracked 를 붙여 로컬과 CI 가 같은 수를 세게 했다. 계수기를 만들면서 계수기의 첫 결함을 계수기가 잡았다.
9월 — 틀린 것은 진단이었다
8월 항목은 대부분 계측기가 틀렸다 는 모양이다. 9월은 다르다: 판정은 매번 맞았고, 그것을 읽은 내가 틀렸다. 이 구분이 이 달의 내용 전부다.
요청 하나가 어느 단계에서 답을 잃었는지 말하지 못했다
2026-09-05 · spans: 캡처를 켜고, 그 줄이 켜져 있는지 지키는 검사를 건다
검색은 경로 둘을 돌리고, 융합하고, 다양성 상한으로 자르고, 절을 채우고, 답변 근거 묶음을 만들고, 생성했다 — 그리고 요청당 평평한 한 줄만 남겼다. 그래서 라이브 질의가 나쁘게 답했을 때, 후보 풀에 애초에 없었다 와 융합이 잃었다 와 다양성 상한이 잘랐다 와 프롬프트까지 갔는데 모델이 안 썼다 를 기록만으로 가를 수 없었다. 가르려면 라벨 위에서 질의를 재현하는 수밖에 없었다.
이제 단계마다 입력·출력·순위 후보를 span 으로 남긴다. 이 단위는 캡처를 끈 채로 나갔다 — 행이 하나도 없는 동안 스키마·제약·파괴적 정리 경로를 CI 에서 돌려 보려고 그랬고, 켜는 것은 날짜가 붙은 별도의 결정이었다. 그 스위치는 설정 한 줄이고 코드 기본값은 꺼짐이라, 줄이 사라져도 모든 검사가 초록으로 남는다 — 그래서 켤 때 자기 검사를 같이 만들고 양쪽으로 깨뜨려 확인한 뒤 되돌렸다.
신호 하나가 서로 다른 실패 셋을 싣고 있었다
2026-09-05 · probe: "모델이 못 뽑았다" 와 "검색이 애초에 안 줬다" 를 가른다
답변 채점기는 요구된 사실이 없다 고 말했다. 그 한 신호가 무관한 원인 셋을 묶고 있었다 — 검색이 그 사실을 아예 안 줬거나, 모델이 갖고도 하나도 안 뽑았거나, 갖고도 일부만 뽑았거나. 그런 실패를 읽어서는 검색을 고칠지 생성을 고칠지 알 수 없다.
가를 재료는 이미 있었다 — 모델이 실제로 본 그 문자열. 이제 답변 근거에 있는지도 답변에 있는지와 같은 정규화로 판정한다. 이 리포에서 채점기 둘의 정규화가 갈려 한 라벨의 판정이 서로 반대로 나온 적이 있기 때문이다. 합치는 규칙은 불리언 목록 둘만 받는다 — 그래야 셋째 정규화가 그 안으로 따라 들어오지 못한다.
첫 실제 결과가 같은 날 나왔다: 필수 사실 둘이 답변 근거에 다 있는데 답이 하나만 낸 정책 라벨. 가르기 전에는 그 줄이 검색 실패와 똑같이 보였다.
하니스 안에서만 돌던 계측기
2026-09-05 · answer: 모든 표면에서 답변의 모양을 남긴다
답변이 형식 요청을 지켰는지 보는 결정론적·모델 없는 검사가 몇 주째 있었다 — 평가 하네스 안에만. 이 로그에서 가장 자주 반복되는 모양이고, 이 달이 끝나기 전에 두 번 더 나온다: 감지기는 있고 전달이 없다.
붙이면서 한계가 하나 드러났는데, 덮지 않고 적었다. 준수 판정은 (요청된 형식, 답변) 둘이 있어야 하는데 라이브에는 요청을 아는 부품이 없다. 그것을 추측하는 감지기를 새로 만들면 오탐이라는 새 실패 유형이 는다. 그래서 라이브는 답변의 모양만 남긴다 — 문장 수·표 유무·목록 항목 수·길이 — 그리고 아무것도 판정하지 않는다. 고정 문장은 측정하지 않고, 기권·생성실패 안내문은 같은 필드를 null 로 적는다. 상수의 문장 수는 측정값이 아니기 때문이다. 필드 자체는 절대 빼지 않는다 — 그것이 다음 사람에게 측정 안 함과 유실을 갈라 주는 유일한 표시다.
하루에 병목을 세 번 잘못 지목했다
2026-09-05 · probe: must_contain 라벨도 귀속하고, 그것에 대해 내가 한 말을 철회한다
이 작업을 정리한 감사는 어긋남들이 없는 계측기 하나로 수렴할 것이라 예측했다. 실제로 수렴했다 — 그리고 그 수렴점이 세 번 옮겨갔고, 옮길 때마다 확인 전에 사실로 적혔다.
| 병목이라고 적은 것 | 무엇이 반증했나 |
|---|---|
| 단계 span 이 없다 | 맞았다 — 붙이니 다음 항목이 열렸다 |
| 사실 둘 이상을 요구하는 라벨이 없다 | 라벨 집합 하나만 봤다. 전수로 세니 그런 라벨이 41건 |
| 코퍼스에 답이 없다 | 답은 다른 테넌트에 있었다 |
| 하니스가 엉뚱한 코퍼스를 겨눴다 | 테넌트를 고치자 넷 중 셋이 즉시 통과했다 |
이 중 둘은 같은 실수다. upstream 이라는 판정은 요구된 사실이 답변 근거에 없었다 는 뜻이고 그건 매번 참이었다. 그 판정은 코퍼스 공백과 검색 실패와 겨냥 오류를 구별하지 않는다 — 가르려면 다른 증거가 필요한데, 아무 증거도 모으지 않고 읽었다.
그 뒤로 규칙 하나: 판정은 상태를 가리키지 원인을 가리키지 않는다. 원인을 적기 전에 같은 판정을 낼 수 있는 다른 것을 먼저 적고, 가서 봐라.
맞아 보이는데 아무것도 지키지 않던 검사 둘
2026-09-05
하루에 두 번, 검사를 쓰고 검토하고 통과시켰는데 — 그 검사가 잡으라고 만든 결함을 일부러 넣어도 그대로 통과했다.
첫째는 기권 문구 제거 규칙이 검색 근거에는 안 걸린다는 것을 단언했는데, 필수 사실 둘이 애초에 기권 구간 밖에 있는 예시를 골라서 제거해도 판정이 안 바뀌었다. 둘째는 검색으로 못 닿음 과 검색으로는 못 닿지만 벡터로는 닿음 을 가른다고 단언했는데 — 그 구분이 측정치를 절반으로 줄인 바로 그 구분이다 — 정작 그 논리가 순수 검사가 닿을 수 없는 DB 함수 안에 살았다.
둘 다 읽어서는 안 나왔다. 둘 다 코드를 일부러 깨뜨리고 스위트가 빨개지는지 보는 것으로 나왔고, 그건 이 리포가 이미 요구하는 절차이며 건너뛸 핑계가 없을 만큼 싸다.
사전 등록이 잡음을 개선으로 보고하는 것을 막았다
2026-09-05 · measure: 라이브가 읽는 범위에서 answer-facts 를, 3회
서명된 라벨 집합이 테넌트 하나 위에서 측정되고 있었는데 라이브 독자는 둘을 본다 — 즉 기록된 모든 수가 아무도 안 묻는 코퍼스에서 나왔다. 맞추려면 서명된 집합이 측정하는 대상을 바꿔야 하므로, 기대를 먼저 자기 커밋에 적었다: 넓히면 경쟁 답변 근거가 늘어나므로 수는 유지되거나 떨어질 수 있다. 떨어지는 것은 회귀가 아니라 첫 정직한 값이다. 오르면 의심하라.
1판은 15/15 로 유지됐다. 2판은 좁은 코퍼스 13/15 에서 넓힌 코퍼스 15/15 로 — 깨끗한 이득처럼 보였다. 그리고 같은 코퍼스에서 3회를 돌리니 15 · 14 · 15 가 나왔다.
그 차이는 실행 간 변동성 안에 있었다. 실행 전에 그 문장을 안 적었으면 코퍼스를 넓힌 공로로 보고됐을 것이다. 그래서 이 회차의 산출물은 수가 아니라 성질이다: 2판은 같은 조건에서 15분의 1 이상 흔들리고 1판은 전혀 안 흔들린다 — 그러므로 하나는 1회로 인용할 수 있고 하나는 못 한다.
아무도 알려 주지 않은 코드 위에서는 페이지가 어긋날 수 없다
2026-09-06 · guard: 공개 페이지가 아직 말하지 않는 기능 넷의 코드를 앵커에 올린다
공개 페이지를 그것이 서술하는 코드에 대 보니 틀린 주장은 없었다 — 그 페이지는 풀 크기를 애초에 말하지 않으므로 값이 바뀌어도 거짓이 된 문장이 없고, 문서 제목이 새던 것을 고친 일은 보안 주장을 덜이 아니라 더 참으로 만들었다.
나온 것은 누락이었다. 출하된 기능 넷이 영문·한국어 어느 쪽에도 없다: 정정 문서를 같이 가져와 정정당한 쪽이 이기지 않게 하는 것, 설계와 그 구현 계획을 함께 가져오는 것, 문서의 값 옆에 코드의 값을 놓는 것, 코퍼스를 하나 넘게 읽는 것. 그중 셋은 코드 기본값이 꺼짐인 설정 스위치이고, 측정된 효과가 각각 옆에 적혀 있다.
⛔ 정정 (2026-09-07). 위 문장은 원래 “그중 셋은 배포 점검이 켜짐으로 못 박는 바로 그 스위치들” 이라고 적혀 있었다. 둘이다. 배포 점검이 못 박는 스위치는 셋인데 그중 하나가 절 채움이고, 그것은 이 페이지가 이미 서술하며 빠진 넷에 속하지 않는다. 빠진 셋째는 아무도 못 박지 않는 스위치다. 이 앵커들이 가리키던 페이지 본문을 쓰다가 잡혔다 — 그리고 손쉬운 수선은 그 스위치를 점검에 추가하는 것이었는데, 그러면 공개된 문장을 참으로 만들려고 세상을 고치는 것이 된다. 오픈 항목으로 등록해 그 자체의 답변 근거로 판단한다.
그것들이 한 번도 눈에 안 띈 이유는 그 코드가 그 페이지의 앵커에 없었기 때문이다. 이제 등록했다 — 그리고 그 기능을 서술하는 페이지에만 걸었다. 페이지가 말하지도 않는 코드를 앵커로 걸면 사람들에게 그 신호를 무시하는 법을 가르치게 된다.
⚠ 한계 하나를 그 수 바로 옆에 적었다: 드리프트 계수는 문서가 왜 편집됐는지 모른다. 용어 일괄 수정 한 번이 그 수를 0 으로 되돌린다. 한국어 페이지가 영문의 17 에 대해 7 을 보이고 있었는데, 내용은 같고 마지막 편집이 낱말 하나를 바꾼 것이었기 때문이다.
가설은 내 것이었고, 그것을 확인하려고 쓴 측정이 그것을 반증했다
2026-09-06 · measure: 재청크가 실제로 얼마를 무는가
밖의 관행은 청크의 식별 정보를 소스 레코드 ID + 그 청크 내용의 해시에서 뽑는다. 그러면 재청크해도 안 바뀐 것의 식별 정보가 유지된다. 이 시스템은 위치에서 뽑는다 — 문서·절 경로·인덱스 — 그리고 그 인덱스는 문서 전체에 걸쳐 단조 증가한다. 거기서 나는 결론을 적었다: 앞쪽에 한 문장을 넣으면 그 아래 식별 정보가 전부 밀리고, 그것을 참조하던 것들이 고아가 된다.
측정은 0 이라고 답했다. 문서 여섯 × 편집 세 모양에서 식별 정보는 하나도 안 움직였다. 작은 편집은 청크 경계를 안 건드리므로 식별 정보가 되는 (절, 인덱스) 짝이 그대로다. 달라지는 것은 한 청크의 본문뿐이다.
움직이는 것은 청크의 수가 바뀔 때다. 청크를 하나 늘릴 만큼 긴 문단을 넣으면 그 뒤가 거의 전부 밀린다 — 22중 21 · 36중 24 · 15중 14 — 그리고 그 경우마다 본문이 실제로 바뀐 청크는 하나였다. 즉 비용을 물 때는 내용이 똑같은 청크에 대해 거의 전부를 문다: 벡터 재계산, 그리고 span·라벨·피드백에서 오는 참조의 고아화.
조치 방법은 채택하지 않았다. 식별 정보 체계를 바꾸는 것은 되돌리기 비싼 변경이고, 그것을 유발하는 편집이 얼마나 자주 오는지를 몰랐다. 그래서 다음 단위가 재적재마다 청크 수 전후를 남기게 했다 — 그 빈도는 그날부터 답할 수 있고, 지난 것에 대해서는 영원히 답할 수 없다.
스탬프가 3주째 쌓이는데 읽는 코드가 없었다
2026-09-06 · feat: 아무도 안 읽던 출처 도장을 읽는다
이 시스템은 같은 결함을 두 번 겪었다: 파생값이 낡았는데 무효화가 그것을 놓쳐 비워지지 않았고, NULL 을 찾는 복구 큐는 그것을 구조적으로 못 본다. 커버리지는 그것을 “채워짐” 으로 세므로 아무 수도 이상해 보이지 않는다. 세 번째가 있느냐는 질문에 훑어보니 없었다 — 파생물 여섯 중 넷이 해시 스탬프로 이미 닫혀 있었고, 가설 셋이 더 배제됐다(하나는 라이브에서 0 을 측정해서).
대신 나온 것이 이 로그에서 가장 자주 반복되는 그 모양이다. 어느 모델이 각 벡터를 썼는지 적는 표는 언제 썼는지도 적는다. 그 필드는 8월 중순부터 채워지고 있었고 읽는 코드가 하나도 없었다.
읽으면 질의 하나로 코퍼스가 갈린다: 벡터가 그 행의 마지막 갱신 이후에 쓰였으면 낡을 수 없다. 그것이 2,045행 중 1,736행을 빼 주고 309행만 남긴다 — 있는데 스테일 벡터를 보는 유일한 방법이 그때까지 전수 재계산이었던 자리에서.
그 수는 일부러 판정이 아니다. 행의 시각은 내용이 안 바뀐 재적재에도 움직이는데 그때는 무효화가 안 걸린다. 그러므로 이 값은 틀린 것의 개수가 아니라 확인할 것의 상한이다. 스탬프가 아예 없는 행은 셋째 상태로 따로 낸다 — 숨기면 모른다 와 괜찮다 가 같아 보이기 때문이다.
대조군이 날짜에 박혀 있어서, 조용히 존재하기를 그만뒀다
2026-09-06
벡터를 재계산해 낡음을 잡는 하니스는 자기 규칙을 맨 위에 달고 있다: 대조군을 먼저 읽어라. 대조군이 깨끗하지 않으면 나머지 수치는 못 쓴다. 그 대조군이 특정 날짜에 편집된 행 으로 정의돼 있었다. 그런 행은 이제 없고, 그래서 대조군 줄이 아예 안 찍힌다 — 그 부재가 침묵으로 표현된다.
이게 문제인 이유는 그 실행이 466 중 0 낡음으로 돌아왔고 캐시된 비교가 전부 정확히 1.000000 이었기 때문이다. 만점은 낡은 게 없다 와 이 계측기는 아무것도 못 가른다 에 똑같이 들어맞는다.
그래서 대조군을 손으로 만들었다: 임시 테넌트에 청크 둘을 넣고 한쪽에 다른 쪽의 벡터를 줬다. 하니스가 둘 중 하나를 0.65 로 잡았다(임계값 0.9999). 그러고 나서야 그 0 이 보고할 값이 됐다.
하니스는 같은 단위에서 고치지 않았다 — 그것이 지금 쓰는 계측기였고, 측정 도중에 계측기를 바꾸는 것이 이 리포가 막는 바로 그 행동이다. 대신 조치 방법을 적어 뒀다: 대조군을 위의 시각 비교로 정의하면 빈 집합으로 썩지 않는다.
검사가 한 번 떨어졌고, 첫 설명은 익숙한 쪽이었다
2026-09-06
전 스위트가 검사 둘에서 빨개졌다. 하나는 진짜 결함이었고 그 검사가 잡은 것이 옳았다: 손으로 만든 DB 대역 — 사실상 어떤 함수가 무엇을 불러도 되는지의 화이트리스트 — 이 방금 추가된 메서드를 안 갖고 있었다. 그 대역이 존재하는 이유가 정확히 그 가시성이다.
다른 하나는 여기에 내 새 검사가 남의 픽스처를 오염시켰다 고 적혔다. 전날 실제로 그런 일이 있었기 때문이다. 성립하지 않는다. 단독으로 통과하고, 새 모듈과 나란히 놓아도 통과하고, 그 뒤 전 스위트 초록에서도 통과했다. 게다가 새 모듈은 알파벳순으로 그것보다 뒤에 온다 — 앞서 돈 것을 흔들 수 없다. 후보 메커니즘 둘은 코드를 읽어 배제했다.
고쳤다고 적지 않고 설명되지 않음으로 남겼다. 그 경로는 결정적 벡터 위의 정확 스캔이고 동점 처리까지 결정적이라, 같은 행이면 같은 답이 나와야 한다 — 즉 행이 달랐다는 뜻인데 무엇이 바꿨는지 아직 아무것도 말해 주지 않는다. 초록 한 번은 부재의 증거가 아니고, 이 리포는 스테일 베이스라인의 초록에 이미 한 번 속았다.
9월이 이 로그에 더하는 것
8월의 교훈은 계측기가 시스템보다 더 자주 틀렸다 였다. 9월은 대부분의 기간 동안 그 반대였고 그쪽이 더 쓸모 있다: 판정은 정확했고, 그것을 읽은 내가 틀렸다. 판정은 상태를 가리킨다. 이 달의 틀린 결론은 전부 그 상태를 원인인 것처럼 다룬 데서 나왔다 — 없는 사실과 좁은 코퍼스와 잘못 겨눈 실행과 오염된 픽스처가 같은 신호를 내고, 그 차이는 아무도 가서 모으지 않은 다른 증거에 있다.
⚠ 그런데 그 틀이 이 절의 마지막 날을 못 넘겼다. 6일에 계측기가 다시 틀렸고, 계측기가 틀릴 수 있는 방식 중 가장 나쁜 쪽으로 틀렸다: 대조군이 날짜에 박혀 있었고, 그 날짜의 행이 사라졌고, 그래서 검사는 못 돌았다 고 보고하는 대신 아무 말도 안 했다. 실패할 수 없는 계측기의 초록은 결과가 아니다. 그래서 이 달은 교훈 둘을 다 열어 둔 채 끝나고, 둘째는 더 오래된 것의 다시 쓰기다: “이건 측정하지 못했다” 고 말할 수 없는 계측기는 언젠가 대신 “괜찮다” 고 말한다.
실제로 통한 방어는 더 좋은 계측기가 아니었다. 실행 전에 적은 사전 등록, 오픈 항목을 서술하지 않고 세는 규칙, 검사를 일부러 깨뜨려 빨개지는지 보는 습관, 그리고 — 이 달에 두 번 — 기록이 답하지 못하는 질문을 던진 사람이었다.
⚠ 그리고 같은 기간이 여기 없는 것의 모양도 보여 준다. 공개된 RAG 실패 유형 열 가지에 대 보면 이 시스템의 진단 질문은 여섯을 덮는다. 못 덮는 넷 — 검색 시점, 맥락 위치 편향, 검색-생성 모델 불일치, 재귀 검색 루프 — 이 빠진 이유는 하나다: 이 시스템에는 그 부품들이 없다. 겪지 않은 실패는 일어날 수 없는 실패와 다르다.