diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 2e36d60..4223ce8 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -77,3 +77,5 @@ | 2026-08-03 | 하루에 유형마다 서너 번씩 난 **반복 사고 세 유형**을 트러블슈팅으로 남겼다(`S15P11A705-293`, T70~T72). **개별 사고의 해결이 아니라 반복 유형을 적는 문서다** — 각 건의 처리는 자기 티켓·PR·이슈에 있고 여기에는 「무엇이 반복됐는가」만 남는다. **유형마다 마지막 사고는 앞선 사고의 대응이 이미 있는 상태에서 났다** — 07-31 색인 사고 다섯 건과 같은 모양이고, 차이는 성실성이 아니라 강제 장치다. ① **사본에서 꺼낸 값**(T70) — 철회된 단정이 그 문서를 근거로 인용하며 되살아나고, 코드 한 행을 인용하며 **바로 다음 행**을 놓친 오독이 **이미 병합된 작업의 설계 근거**가 되고, 원장에 적어 둔 정정이 계약에서 원래 문구로 되돌아오고, 계약에 실은 수치가 여러 번 틀렸다. **넷 다 사본이 아니라 원문을 연 다른 사람이 잡았다** — 「쓰기 직전 재조회」 규칙은 이미 있었는데도 났다. 사본은 자기가 낡았다고 말하지 않으므로 대응을 읽는 쪽이 아니라 **쓰는 쪽**에 뒀다(`R-18` 「값 대신 출처」). 철회하는 쪽도 대상을 좁힌다 — **폐기된 것은 값이 아니라 값의 항상성이다.** ② **반만 덮는 자동화**(T71) — 「성공했다」와 「실제로 반영됐다」가 다르고 실패가 조용하다. **성공 판정을 네 층으로 가른다**(프로세스 생존 ≠ 실행 성공 ≠ 실제 변경 발생 ≠ 최종 전달 성공). 감시 고아는 heartbeat 로 고쳤고(판정은 사람이 한다), 배포 자동화 둘은 인프라 소관이라 이슈로 기록만 했다. **「다음 스케줄이 복구한다」는 전제 자체가 약하다.** ③ **공개 저장소 실사용자 기록**(T72) — 훅이 **바깥으로 나가는 발화**만 검사해 스크립트가 만들고 사람이 `git add` 한 산출물은 검사 지점을 한 번도 안 거쳤고, `.gitignore` 예외가 「커밋하라」고 **적극적으로 지시**하고 있었다. **대응 미정** — 선택지 넷 중 마스킹 문구의 경로 안내 제거만 즉시 가능하다. 위치·규모는 적지 않는다(위치 비공개 선례). **이 문서 자신이 T70 의 첫 시험이라 수치·코드 행 번호를 옮기지 않고 좌표만 가리킨다** | [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 표시명 개정(`S15P11A705-292`)이 코드에서 운영 화면까지 간 **배포 체인을 기록했다**(I50). **체인 여섯 단계의 소관을 갈랐다** — 우리(`ai#102` dev · `ai#104` 릴리스) · 자동화(`image-publish` 는 별도 워크플로가 아니라 CI 안의 job 이고 `main` push 조건이라 **`dev` 병합만으로는 이미지가 안 만들어진다**) · 인프라 소관(`infra#185`) · 클러스터 sync · 화면. **핵심은 미결 판정 하나를 실측으로 닫은 것이다** — 프리셋 봉인 값이 개정 전 값 그대로인 채 배포됐는데 새 표시명이 화면에 나왔다. 따라서 그 값은 **판 표기이지 부트스트랩 재실행 조건이 아니다**(`BeforeHookCreation` 이 같은 이름 Job 을 지우고 다시 만들고, 이미지 태그가 바뀌면 sync 가 돌며, 적재가 멱등이다). 그 전에는 중앙이 「값이 같으면 재실행되지 않는다」로 판정했고 독립 검증이 논거를 반증했으나 **클러스터 관측 경로가 없어 「확인 불가」로 끝나 있었다** — 배포가 답을 냈다. **그래도 표기가 낡은 것은 기록 정합성 문제로 남는다**(배포를 막지 않을 뿐이고 틀려도 실패하는 검사가 없다). **릴리스가 두 번 막혔고 둘 다 다음에 그대로 다시 만난다** — base 검사는 `main` base 를 `release/*`·`hotfix/*` 로 한정하므로 `dev` 를 그대로 열면 CI 가 막고(`ai#103` 이 그렇게 닫혔다), strict 상태 검사는 BEHIND 를 거부하므로 `main` 을 먼저 병합해 해소한다(병합 커밋 `032e391f`). 갱신 자동화의 예정 회차가 릴리스 병합 **직전**에 돌아 변경 없이 끝났고, 갱신을 만든 것은 수동 실행이라 **`success` 두 번의 뜻이 다르다.** 확인 수단이 화면뿐이라 「아직인가 실패인가」가 갈리지 않아 한 번 오판했다가 재확인에서 뒤집혔다. **값은 옮기지 않고 좌표만 적었다**(`T70` 적용) | [배포 체인 리포트](implements/2026-08-03-preset-display-name-deploy-chain.md), [I48](implements/2026-08-03-dev-deploy-gap.md), [T70~T72](troubleshooting/2026-08-03-repeat-incidents.md) | | 2026-08-03 | 외부 리뷰 문서를 **P47 제안 문서로 다시 썼다** — 프리셋의 표시 라벨·축 정의·스키마 개정안(`S15P11A705-228`·`-269`·`-270`·`-271`·`-292`, `ai#90` 후속). **proposals 최초의 `Proposed`** 다 — §7 이후가 미실행이고 §11 실측이 없다. **다만 §6(표시 라벨)은 문서를 쓰는 사이에 반영이 끝났다** — `-292` 가 `display_name` 을 명사·명사구로 통일해 `ai#102`(`dev`)·`ai#104`(`main`)로 나갔고, 그래서 그 절만 제안이 아니라 **기록**으로 다시 썼다(제목에 「반영 완료」 · 표의 「권장 표시명」을 「반영값」으로 · 정본은 문서가 아니라 `data/keyword_preset.yaml` 임을 명시). **초안과 실제가 갈린 셋을 확정으로 남겼다** — `ALONE` 은 초안의 `1인` 을 기각하고 `혼자` 를 유지했고(축의 나머지가 관계어인데 수량 표현만 이질적이다), `TRENDY` 는 `examples` 가 유행이 아니라 미감을 가리키므로 `감성` 으로 확정했으며, `RETRO` 는 **초안에 없던 결정**으로 `복고` 가 됐다(같은 축의 어휘 층위 통일 · `description` 과 정합). **선결 조건이 통째로 바뀌었다** — 「프리셋을 고치면 기존 판정을 전부 재처리한다」로 정해지면서 「판을 구분할 경로」(행 `version` 을 올릴 경로가 없다는 사실, `-269`)가 선결에서 §3-F 로 내려갔고, 그 자리에 **「재처리할 수단이 없다」**가 들어갔다: 완료 State 를 되돌리는 코드가 없고 · 재스캔이 잡는 상태에 완료가 없으며 · 한 번에 밀면 게이트웨이 한도에 걸려 재시도 예산이 소진되면 실패로 굳는다(부재의 범위는 `ai#100`). **「정할 것」이 아니라 「만들 것」이라 §13 0단계가 구현 항목이 됐고**, 같은 이유로 §10.3 세트 release 는 운영 용도가 빠지고 평가 재현만 남아 우선순위가 내려갔다. **원본의 다섯 곳을 정정했다**: ① Feed 표시 정렬을 절째로 빼고 `back` P46 을 가리켰다(원본 제안이 그 결정이 **명시적으로 기각한** 방향이었다), ② 「CI 에서 검증되지 않는 것」 목록을 없애고 방향과 「테스트 코드가 정본」만 남겼다(이미 있는 테스트를 없다고 적고 있었다), ③ 배포 서술을 hook 메커니즘 존재라는 구조 사실로 줄였다(나머지는 `infra` 소관), ④ `version` 을 「세트 release 와 분리」가 아니라 판 식별 문제로 재배치, ⑤ 표시명 오염을 「임베딩 입력」이 아니라 **임베딩·판정 프롬프트 양쪽**으로 고쳤다 — 그래서 이 변경은 임베딩 실험이 아니라 프롬프트 실험 대상이다. **구조 사실 일곱을 보강했다**(개정에 기존 판정 재처리가 따라붙는다 · 후보 슬롯 경쟁으로 프리셋 단위 개선이 전역에서 상쇄된다 · 저빈도 프리셋 처분이 라벨 개정보다 앞선다 · 표시명 개정이 백엔드 조회의 정렬과 중복 흡수에 파급된다 · **`is_active` 를 끌 경로가 없어 tombstone 은 구멍 메우기다** · 공용 계약 개정 단계 · 임베딩 비결정성 때문에 1회 측정으로 채택을 가르지 않는다). **값을 쓰지 않았다** — 측정 수치·현행 상수·행 번호·배포 상태를 전부 출처 참조로 바꿨고 남은 숫자는 §6.2 의 반영값뿐인데 그것도 YAML 이 정본임을 표 앞에 못박았다. 문서가 값을 안고 있으면 값이 바뀔 때 문서만 낡는다. **`§6` 이 실측 없이 먼저 나간 것은 §14 에 감수 항목으로 남겼다** — 표시 계층만 손댄 범위였기 때문이고, 대가로 라벨 개정이 판정에 얼마나 파급됐는지는 재지 않은 채 §11 에 남는다 | [P47](proposals/P47-keyword-preset-label-axis.md), [근거 리포트](implements/2026-08-03-preset-description.md), [T68·T69](troubleshooting/2026-08-03-preset-description.md) | +| 2026-08-07 | 결합 신뢰도 게이트(중앙 조정 세션 인계 문서 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) 의 threshold 후보 0.35 를 오프라인으로 재측정했다(`S15P11A705-401`). 0.35 는 원래 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인 판정값)에서 가져온 값이라 이 용도(S1 단독·저유사도 결과를 숨기는 게이트)로는 검증된 적이 없었다. `gate_sweep.py` 로 문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건에 게이트 규칙을 적용해 보니 threshold 0.35 에서 정답 손실 0 을 유지하면서 무관 노출이 크게 줄었고(단어무관 23/45·타인소유 55/96 신규 침묵), 손실 0 구간의 최적값(0.36)과도 사실상 같았다. **최초 실행에서는 프로브 6 건이 손실로 나왔는데, 원인은 `recall_probe.json` 이 질의별이 아니라 최상위에 `user_id` 를 두는 형식을 놓친 것이었다** — 고치자 손실이 threshold 0.38 까지 0 건으로 나왔다. 0.35 채택을 권고한다 | [리포트](implements/2026-08-07-gate-threshold-remeasure.md) | +| 2026-08-07 | 401 리포트 작성 뒤 `test_docs_index.py`가 `docs/implements/README.md`의 색인 두 표(개별 리포트·구현·산출 전수) 모두에서 I58(`2026-08-07-gate-threshold-remeasure.md`) 행이 빠진 것을 잡았다 — 리포트를 커밋하면서 색인 갱신을 놓친 것. 두 표 모두에 행을 추가해 정정했다 | [gate_sweep 리포트](implements/2026-08-07-gate-threshold-remeasure.md) | diff --git a/docs/implements/2026-08-07-gate-threshold-remeasure.md b/docs/implements/2026-08-07-gate-threshold-remeasure.md new file mode 100644 index 0000000..850b270 --- /dev/null +++ b/docs/implements/2026-08-07-gate-threshold-remeasure.md @@ -0,0 +1,52 @@ +# 결합 신뢰도 게이트 임계값 오프라인 재측정 + +- **티켓**: S15P11A705-401 +- **날짜**: 2026-08-07 +- **하네스**: `tools/search_cut/gate_sweep.py` — 실행 절차·데이터 원본은 그 파일 docstring +- **기준 문서**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · [P49](../proposals/P49-multi-signal-search.md) §9(0.35의 원출처) · 선행 실측 [I57](2026-08-06-rerank-adoption.md) +- **성격**: 재기만 한다. 앱 런타임 코드를 바꾸지 않는다. 기존에 커밋된 행렬만 읽었다(DB·GMS 호출 0회). + +## 한눈에 보는 결과 + +| 항목 | 확인한 내용 | 판단 | +|---|---|---| +| 0.35 재사용 안전성 | 문장형 정답 12건·단어형 정답 66건·진단프로브 22건 어디에서도 threshold=0.35에서 정답 손실이 0건이다 | 이 데이터에서는 **안전하다** — 재사용해도 된다 | +| 0.35의 최적성 | 정답 손실 0을 유지하는 구간(0.25~0.37)에서 최적값은 0.36이고, 0.35는 그보다 침묵 2건(타인소유 1·단어무관 1) 적을 뿐이다 | 0.35는 최적에 근접한다 — 별도 값을 채택할 실익이 없다 | +| 무관 노출 감소 | 0.35에서 문장무관 3/15·단어무관 23/45·타인소유 55/96이 추가로 침묵한다(현재 0건 대비) | 게이트가 실제로 노출을 크게 줄인다 | +| 재사용 판단 근거 | P48→P49 구조 전환 때 우연히 같은 숫자가 재확정됐던 선례(I57)와 달리, 이번엔 **구조가 또 달라** 실제로 다시 쟀다 | "값이 같다고 검증을 생략하지 않는다"는 이 프로젝트 관행을 그대로 따랐다 | + +## 배경 + +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3은 새 게이트(S1 단독·유사도 **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -121,5 +122,6 @@ | I55 | 재작성 효과 프로브 `tools/search_cut/rewrite_probe.py` — 실제 RewriteClient(운영 설정 체인) 호출 → 재작성문 임베딩 배치 1회 → 스냅샷 DB(:25432) 유사도 → 현행 컷 재구성(단어형/문장형 분기는 재작성문 기준, 서비스 미import 재구성 규칙). **재작성문 자체를 artifact 에 기록** — 무엇으로 바뀌었는지가 판정 근거의 절반. 원문 기준 값은 굳힌 행렬의 컷 재구성으로 얻어 GMS 호출 최소화, 재구성 무노출 11/15 가 기존 관측과 일치(교차 확인). 게이트(`should_rewrite` 재구성) 반영 후에는 게이트 통과 질의만 재작성·재임베딩 — 걸린 질의는 원문 값이 곧 결과(서비스와 동일). 강등 0건·전 질의 1순위 벤더 응답 관측 | [효과 리포트](2026-08-06-rewrite-effect.md), [I54](2026-08-06-lexical-merge-rule.md), [P49](../proposals/P49-multi-signal-search.md) | | I56 | 키워드 재정렬 하네스·런타임 `tools/search_cut/fusion_rerank_sweep.py`(재정렬 전용 격자 + 신선도 가드 + 후보 불변 검사) · `fusion.rerank()`(순수 함수) · `rerank_verify_live.py`(실서버 on/off 5판정) · `app/service/search_service.py`(`_rerank_by_keyword` — 질의 임베딩 재사용·추가 모델 호출 0) · `app/repository/context_keyword_repo.keywords_for_records` · `SEARCH_KEYWORD_RERANK_*` 4키(기본 off·floor 0.35·w 0.05·top_k 3) — 채택값은 재정렬 전용 구조 재측정으로 확정(I53 과 숫자가 같지만 다른 측정), 결정성 회차 JSON 바이트 일치, 실서버 대조 93건 5판정 전부 통과. **번호 잠정 I56** — 병렬 잠정 I55 충돌을 통합 병합에서 재확정(설계된 절차), 최종 확정은 dev 병합 직후 | [재정렬 리포트](2026-08-06-rerank-adoption.md), [I53](2026-08-05-fusion-measurement.md), [P49](../proposals/P49-multi-signal-search.md), [tools/search_cut/](../../tools/search_cut/) | | I57 | 표시명 정합 회복·재측정·게이트 재검증 — `app/bootstrap/load_presets` 재적재로 프리셋 표시명(-292 명사형)을 정본에 맞추고, 그 조작이 무효화한 측정을 전부 다시 뜬다. 조작 범위는 md5 4종 대조로 고정(프리셋 임베딩만 변경 · Context 임베딩·판정 상태·context_keyword 83행 불변). `keyword_matrix` 재생성 → `fusion_rerank_sweep` 격자 재실행(결정성 2회 일치) → 게이트 3 phase 전량. **채택값 유지**(퇴행 없이 단어형 개선) · **문장형 개선 소멸**(floor 0.40 구간으로 이동) · 기준선 인접 쌍 9건이 임베딩 흔들림 폭 이내라는 한계. `keyword_matrix.py` 포트 가드를 시연 DB(:15432)에서 스냅샷(:25432)으로 정정 | [재검증 리포트](2026-08-07-preset-label-realign-gate.md), [I56](2026-08-06-rerank-adoption.md), [P49](../proposals/P49-multi-signal-search.md) | +| I58 | 결합 신뢰도 게이트 threshold 재측정 하네스 `tools/search_cut/gate_sweep.py` — 기존 행렬(`matrix.json`·`word_grid.json`·`recall_probe.json`·`lexical_matrix.json`·`keyword_matrix.json`)만 읽어 S1(벡터)·S2(문자열, 채택 게이트 G1)·S3(키워드, 채택값 binary·floor 0.35·top_k 3) 신호를 재구성하고 threshold 격자(0.0·0.16~0.40)를 훑는다. **0.35에서 정답 손실 0**(문장형 12·단어형 66·프로브 22 전부)이면서 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵) — 안전 구간(0.25~0.37) 안의 최적값(0.36)과도 사실상 같다. DB·GMS 호출 0회, 결정성 확인(같은 입력 2회 실행 바이트 일치) | [재측정 리포트](2026-08-07-gate-threshold-remeasure.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.3, [I56](2026-08-06-rerank-adoption.md), [tools/search_cut/](../../tools/search_cut/) | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. diff --git a/tools/search_cut/gate_sweep.py b/tools/search_cut/gate_sweep.py new file mode 100644 index 0000000..2adb3a2 --- /dev/null +++ b/tools/search_cut/gate_sweep.py @@ -0,0 +1,364 @@ +"""결합 신뢰도 게이트 임계값 오프라인 재측정 — S15P11A705-401. + +배경은 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4다. 그 +문서는 결과를 아예 숨길지 정하는 새 게이트를 제안한다 — 신호가 S1(벡터 컷 통과) 하나뿐이고 +그 유사도가 임계값 미만이면 응답에서 뺀다. **임계값 후보로 0.35 를 들었지만, 그 값은 이 +용도로 측정된 적이 없다.** 0.35 의 원출처는 `SEARCH_KEYWORD_RERANK_FLOOR` +(`app/core/config.py`) — 키워드 재정렬에서 "질의와 Preset 이 같은 의미인지" 판단하는 +값이다. 그 값이 재정렬 전용 구조로 바뀌었을 때도 이 레포는 그대로 재사용하지 않고 +`fusion_rerank_sweep.py`(P49 작업 4)로 **다시 쟀다** — 우연히 같은 숫자가 다시 채택됐을 +뿐, 그것은 다른 측정의 결과였다(`2026-08-06-rerank-adoption.md`). 이 스크립트는 그 +선례를 따른다: "벡터 신호 단독일 때 그것이 약한지"는 "질의-Preset 코사인이 같은 +의미인지"와 다른 질문이므로, 같은 값을 가정하지 않고 이 용도로 다시 잰다. + +**DB 도 GMS 도 부르지 않는다.** 기존에 커밋된 행렬만 읽는다. + + matrix.json 문장형 정답 12건 · 무관 15건 (S15P11A705-213) + word_grid.json 단어형 정답 66건 · 무관 45건 · 타인소유 96건 (S15P11A705-266) + recall_probe.json 진단 프로브 (S15P11A705-255) + lexical_matrix.json S2(문자열 매치) 원본 — 채택 게이트 G1(단어형 한정·부분일치, I54) + keyword_matrix.json S3(키워드 매치) 원본 — 채택값 binary·floor 0.35·weight 0.05· + top_k 3 (I57, `app/core/config.py` 정본) + +## 게이트 규칙 + +컷을 통과한 후보마다 S1(항상 참) · S2(문자열 매치) · S3(키워드 매치)를 센다. **S2 도 S3 +도 없고** 유사도가 `threshold` 미만이면 그 후보를 뺀다. 하나라도 있으면 유사도와 무관하게 +남긴다 — 이 스크립트가 답하는 것은 그 `threshold` 값이다. + +이 게이트는 컷·재정렬과 **독립된 마지막 단계**다(OFFTOPIC 문서 §4.1 — 기존 계약 +"재정렬은 후보를 추가·제거하지 않는다"는 재정렬 자신의 계약이고 이 게이트의 계약이 +아니다). 그래서 여기서는 재정렬의 순서 결과를 쓰지 않고 신호의 **존재 여부**만 쓴다. + + python tools/search_cut/gate_sweep.py + python tools/search_cut/gate_sweep.py --threshold 0.30,0.35,0.40 +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import fusion as F # noqa: E402 +from rank_score import ( # noqa: E402 + SERVICE_LIMIT, + GuardError, + cut, + is_word_query, +) + +ROOT = Path(__file__).resolve().parents[2] +SEARCH = ROOT / ".search" + +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + + +def log(msg: str = "") -> None: + print(msg, flush=True) + + +def head(title: str) -> None: + log("\n" + "=" * 100) + log(title) + log("=" * 100) + + +# 채택 재정렬 파라미터 — S3 신호를 재현하는 데 쓴다. 정본은 `app/core/config.py`, +# 실측 근거는 `docs/implements/2026-08-06-rerank-adoption.md`(I57). +RERANK_METHOD = F.BINARY +RERANK_FLOOR = 0.35 +RERANK_TOP_K = 3 + +# 채택 문자열 게이트 — S2 신호를 재현하는 데 쓴다(I54: 단어형 한정 · 부분일치). +LEXICAL_GATE = "G1" + +# 이 티켓이 훑는 임계값 후보. 0.35 를 격자 한가운데 두고 촘촘히(0.01) 잡는다 — +# `word_sweep.py` 가 τ_abs 를 잡은 것과 같은 간격 선택 이유다(채택 후보가 데이터점 +# 하나에 붙을 수 있다). +THRESHOLDS = [0.0] + [round(0.16 + 0.01 * i, 3) for i in range(25)] + + +# ------------------------------------------------------------------------- 적재 + + +def load(search_dir: Path = SEARCH): + paths = { + "matrix": search_dir / "matrix.json", + "word_grid": search_dir / "word_grid.json", + "recall_probe": search_dir / "recall_probe.json", + "lexical": search_dir / "lexical_matrix.json", + "keyword": search_dir / "keyword_matrix.json", + } + for k, p in paths.items(): + if not p.exists(): + raise GuardError( + f"{p.relative_to(ROOT)} 가 없다. README(tools/search_cut/README.md)의 " + "해당 matrix 스크립트를 먼저 돌려라." + ) + data = {k: json.loads(p.read_text(encoding="utf-8")) for k, p in paths.items()} + + profiles = {k: d.get("profile") for k, d in data.items()} + if len(set(profiles.values())) != 1 or None in profiles.values(): + raise GuardError(f"Profile 이 어긋난다 — 한 표에 놓을 수 없다: {profiles}") + + lex = {e["query"]: e["matches"] for e in data["lexical"]["queries"]} + + kw = data["keyword"] + presets = { + p["id"]: F.Preset(id=p["id"], version=p["version"], visibility=p["visibility"]) + for p in kw["presets"] + } + contexts = [ + F.ContextKeywords( + context_id=c["context_id"], + record_id=c["record_id"], + keyword_status=c["keyword_status"], + keywords=tuple((k["keyword_id"], k["confidence"]) for k in c["keywords"]), + ) + for c in kw["contexts"] + ] + by_user: dict[int, list] = {} + for c, raw in zip(contexts, kw["contexts"]): + by_user.setdefault(raw["user_id"], []).append(c) + query_cos = { + e["query"]: {c["preset_id"]: c["cos"] for c in e["cos"]} + for e in kw["query_preset"] + } + + # recall_probe 는 `is_expected` 가 없다 — `expect`(장소명)로 합성한다(rank_score.py + # `_probe_segment` 와 같은 규칙). + for e in data["recall_probe"]["queries"]: + want = e.get("expect") + for r in e.get("results") or []: + r["is_expected"] = r.get("name") == want + + return data, lex, presets, by_user, query_cos + + +# --------------------------------------------------------------------------- 신호 + + +def lexical_ids(lex: dict, query: str, uid, gate: str = LEXICAL_GATE) -> set[int]: + """S2 — 채택 문자열 게이트(G1)를 통과한 매치 Record id.""" + if gate in ("G1", "G2") and not is_word_query(query): + return set() + entries = (lex.get(query) or {}).get(str(uid)) or [] + if gate == "G2": + entries = [e for e in entries if e["boundary"]] + return {e["record_id"] for e in entries} + + +def keyword_ids(query_cos: dict, presets: dict, by_user: dict, query: str, uid) -> set[int]: + """S3 — 채택 재정렬 파라미터로 신호가 있는(> 0) Record id.""" + qc = query_cos.get(query) + if qc is None or uid not in by_user: + return set() + cand = F.preset_candidates(qc, presets, top_k=RERANK_TOP_K, floor=RERANK_FLOOR) + sig = F.record_signals(by_user.get(uid, []), cand, presets, method=RERANK_METHOD) + return {rid for rid, s in sig.items() if s > 0} + + +def gate( + rows: list[dict], query: str, uid, threshold: float, *, + lex: dict, query_cos: dict, presets: dict, by_user: dict, limit: int = SERVICE_LIMIT, +) -> list[dict]: + """S1/S2/S3 를 세어 게이트를 적용한다(OFFTOPIC 문서 §4.3). + + S2·S3 가 하나도 없고 S1 유사도가 `threshold` 미만이면 뺀다. `threshold=0.0` 은 + 게이트가 없는 현재 동작과 같다(모든 것이 통과). + """ + kept = cut(rows, query, limit=limit) + if not kept: + return kept + lex_ids = lexical_ids(lex, query, uid) + kw_ids = keyword_ids(query_cos, presets, by_user, query, uid) + out = [] + for r in kept: + rid = r["record_id"] + if rid not in lex_ids and rid not in kw_ids and float(r["sim"]) < threshold: + continue + out.append(r) + return out + + +# --------------------------------------------------------------------------- 평가 + + +def eval_answerable(entries: list[dict], threshold: float, ctx: dict, default_uid=None) -> dict: + """기대 정답이 있는 질의 집합. 게이트가 정답까지 지웠는지를 센다.""" + miss_all = miss_partial = lost_top1 = 0 + lost: list[dict] = [] + for e in entries: + q, uid = e["query"], e.get("user_id", default_uid) + rows = e.get("results") or [] + base = cut(rows, q, limit=SERVICE_LIMIT) + want = [r for r in base if r.get("is_expected")] + if not want: + continue + gated = gate(rows, q, uid, threshold, **ctx) + gated_ids = {r["record_id"] for r in gated} + top1 = next((r for r in want if r.get("rank") == 1), None) + if top1 and top1["record_id"] not in gated_ids: + lost_top1 += 1 + alive = [r for r in want if r["record_id"] in gated_ids] + if not alive: + miss_all += 1 + lost.append({"query": q, "sim": max(r["sim"] for r in want)}) + elif len(alive) < len(want): + miss_partial += 1 + return {"n": len(entries), "miss_all": miss_all, "miss_partial": miss_partial, + "lost_top1": lost_top1, "lost": lost} + + +def eval_control(entries: list[dict], threshold: float, ctx: dict, default_uid=None) -> dict: + """정답이 없는 질의 집합. 게이트를 더 걸수록 침묵(0건)이 늘어야 개선이다.""" + before_silenced = after_silenced = 0 + for e in entries: + q, uid = e["query"], e.get("user_id", default_uid) + rows = e.get("results") or [] + if not cut(rows, q, limit=SERVICE_LIMIT): + before_silenced += 1 + if not gate(rows, q, uid, threshold, **ctx): + after_silenced += 1 + n = len(entries) + return {"n": n, "before": before_silenced, "after": after_silenced, + "gained": after_silenced - before_silenced} + + +def table(title: str, rows: list[dict]) -> None: + head(title) + log(f" {'threshold':>9} │ {'문장정답손실':>10} {'단어정답손실':>10} {'프로브손실':>8} " + f"{'1위손실(문/단)':>14} │ {'무관-문장 신규침묵':>16} {'무관-단어 신규침묵':>16} " + f"{'타인소유 신규침묵':>16}") + log(" " + "-" * 120) + for r in rows: + sa, wa, pa = r["sentence_answer"], r["word_answer"], r["probe"] + so, wo, co = r["sentence_offtopic"], r["word_offtopic"], r["cross"] + top1 = f"{sa['lost_top1']}/{wa['lost_top1']}" + log(f" {r['threshold']:>9.3f} │ {sa['miss_all']:>6}/{sa['n']:<3} " + f"{wa['miss_all']:>6}/{wa['n']:<3} {pa['miss_all']:>4}/{pa['n']:<3} " + f"{top1:>14} │ {so['gained']:>10}/{so['n']:<3} {wo['gained']:>10}/{wo['n']:<3} " + f"{co['gained']:>10}/{co['n']:<3}") + + +def main() -> int: + ap = argparse.ArgumentParser(description="결합 신뢰도 게이트 임계값 재측정 (S15P11A705-401)") + ap.add_argument("--threshold", default="", help="비우면 0.0 및 0.16~0.40 (0.01 간격)") + ap.add_argument("--out", default=str(SEARCH / "gate_sweep.json")) + args = ap.parse_args() + + try: + data, lex, presets, by_user, query_cos = load() + except GuardError as exc: + print(f"[가드] {exc}", file=sys.stderr) + return 1 + + ctx = {"lex": lex, "query_cos": query_cos, "presets": presets, "by_user": by_user} + + thresholds = ([float(x) for x in args.threshold.split(",") if x.strip()] or THRESHOLDS) + + head("입력") + log(f" 문장형 정답 {data['matrix']['query_count']}건 · 무관 " + f"{data['matrix']['offtopic_count']}건") + log(f" 단어형 정답 {data['word_grid']['word_count']}건 · 무관 " + f"{data['word_grid']['offtopic_count']}건 · 타인소유 {data['word_grid']['cross_count']}건") + log(f" 진단프로브 {len(data['recall_probe']['queries'])}건") + log(f" S2 게이트 {LEXICAL_GATE}(단어형 한정 · 부분일치, I54 채택값)") + log(f" S3 파라미터 {RERANK_METHOD} · floor={RERANK_FLOOR} · top_k={RERANK_TOP_K} " + "(I57 채택값, app/core/config.py)") + log(" 호출 DB 0회 · GMS 0회") + + probe_uid = data["recall_probe"].get("user_id") + + rows = [] + for t in thresholds: + rows.append({ + "threshold": t, + "sentence_answer": eval_answerable(data["matrix"]["queries"], t, ctx), + "word_answer": eval_answerable(data["word_grid"]["queries"], t, ctx), + "probe": eval_answerable(data["recall_probe"]["queries"], t, ctx, + default_uid=probe_uid), + "sentence_offtopic": eval_control(data["matrix"]["offtopic"], t, ctx), + "word_offtopic": eval_control(data["word_grid"]["offtopic"], t, ctx), + "cross": eval_control(data["word_grid"]["cross"], t, ctx), + }) + + table("threshold 격자 — 정답 손실 대 무관 신규 침묵", rows) + + head("현재 후보값 0.35 에서 잃는 정답") + at_035 = next((r for r in rows if abs(r["threshold"] - 0.35) < 1e-9), None) + if at_035 is None: + log(" 격자에 0.35 가 없다 — --threshold 로 추가해서 다시 돌려라.") + else: + for label, seg in (("문장형", at_035["sentence_answer"]), ("단어형", at_035["word_answer"]), + ("프로브", at_035["probe"])): + if seg["lost"]: + log(f" {label}: " + "; ".join( + f"「{d['query']}」(sim={d['sim']:.4f})" for d in seg["lost"])) + else: + log(f" {label}: 손실 없음") + + # 정답 손실이 전혀 없는(문장형·단어형·프로브 miss_all=0, lost_top1=0) 임계값만 + # 후보로 남긴다 — word_sweep.py 의 「safe」와 같은 원칙이다. 그중 무관/타인소유 + # 신규 침묵이 가장 큰 값을 위로 올린다. + safe = [ + r for r in rows + if r["sentence_answer"]["miss_all"] == 0 and r["sentence_answer"]["lost_top1"] == 0 + and r["word_answer"]["miss_all"] == 0 and r["word_answer"]["lost_top1"] == 0 + and r["probe"]["miss_all"] == 0 + ] + head(f"정답 손실 0 인 threshold: {len(safe)}/{len(rows)}") + if safe: + ranked = sorted( + safe, + key=lambda r: -(r["sentence_offtopic"]["gained"] + r["word_offtopic"]["gained"] + + r["cross"]["gained"]), + ) + table("정답 손실 0 후보 — 무관/타인소유 신규 침묵 많은 순", ranked) + best = ranked[0] + log(f"\n 이 데이터에서 정답 손실 없이 가장 많이 침묵시키는 threshold: " + f"{best['threshold']}") + def _silenced(r: dict) -> int: + return (r["sentence_offtopic"]["gained"] + r["word_offtopic"]["gained"] + + r["cross"]["gained"]) + + if abs(best["threshold"] - 0.35) > 1e-9: + gap = (_silenced(best) - _silenced(at_035)) if at_035 else "?" + log(f" → 0.35 와 다르다. 0.35 를 그대로 채택하면 이 데이터 기준으로 " + f"{gap}건만큼 침묵 기회를 덜 쓰는 것이다" + "(안전한 방향이지만 최적은 아니다).") + else: + log(" → 0.35 가 이 데이터에서도 최적이다. 재사용을 채택해도 된다.") + else: + log(" 정답 손실이 0 인 threshold 가 격자에 없다 — 이 게이트 설계 자체를") + log(" 재검토해야 한다(모든 임계값이 어떤 정답을 희생시킨다).") + + log("\n주의") + log(" · Record 42건 · 소유자 3명 규모다. 절대 채택값이 아니라 **방향과 상대 비교**로") + log(" 읽는다(rank_score.py 의 같은 경고와 원칙이 같다).") + log(" · 유사도 값 자체의 회차 간 흔들림은 재측정하지 않았다 — 기존 실측(T68,") + log(" |Δsim| 최대 0.0044)을 그대로 인용한다. 이 스크립트는 그 흔들림 폭보다") + log(" 좁은 임계값 비교(0.01 간격)에 대해서는 인접 값 결론을 보류해야 한다.") + log(" · S2·S3 는 이 스크립트가 채택 파라미터로 재구성한 값이다 — 실서버 게이트") + log(" 구현(S15P11A705-400) 이후에는 실서버 대조로 한 번 더 검증해야 한다.") + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps( + {"ticket": "S15P11A705-401", "lexical_gate": LEXICAL_GATE, + "rerank_params": {"method": RERANK_METHOD, "floor": RERANK_FLOOR, + "top_k": RERANK_TOP_K}, + "grid": rows}, ensure_ascii=False, indent=2), encoding="utf-8") + log(f"\n → {out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())