diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 46ccadc..30ea306 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -78,5 +78,6 @@ | 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 | 검색 응답에 `keywordMatched` 필드를 추가했다(`S15P11A705-399`). 키워드 재정렬(`_rerank_by_keyword`, P49 §4)이 이미 계산하지만 정렬에만 쓰고 버리던 match 여부를 응답까지 살렸다 — 재정렬을 `tuple[list, set[int]]`로 바꿔 match 한 Record id 를 함께 반환하고, 재정렬이 생략되는 모든 경로에서는 빈 집합이라 자연히 `False` 다. `similarity` 는 원래 코사인 그대로 두어 정렬 점수가 새어 나가지 않는다는 기존 계약을 지켰다. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호를 준비하는 작업이다 | [리포트](implements/2026-08-07-search-keyword-matched-field.md) | +| 2026-08-07 | 재정렬 후보 불변 계약(`test_search_rerank.py` ②)과 결합 신뢰도 게이트(back 레포 `S15P11A705-400`, BD-52)의 계약을 분리 명시했다(`S15P11A705-402`, 중앙 조정 세션 인계 문서 §4.5가 요구한 검증). 재정렬은 후보를 안 지우지만 게이트는 정반대로 의도적으로 지운다 — 같은 파일에 다섯 계약만 있으면 게이트도 후보 불변을 지켜야 한다는 오독이 생긴다. docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보(기존 컷은 통과·게이트 임계값 미만·신호 없음)도 재정렬은 순서만 밀 뿐 지우지 않는다는 것을 새 테스트로 고정했다. 이 브랜치는 `-399`(I59) 병합 전 갈라져 같은 색인 누락을 독립적으로 I58 로 잡아 두고 있었다 — `dev` 병합에서 두 번호가 부딪혀(`-399` 가 먼저 `I59` 로 확정 병합) 이 리포트는 **I60 으로 재확정**했다(T60 과 같은 패턴, `check_docs_index.py` 의 번호는 병합 시점 이후에 확정한다는 원칙대로) | [경계 리포트](implements/2026-08-07-rerank-gate-contract-boundary.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-rerank-gate-contract-boundary.md b/docs/implements/2026-08-07-rerank-gate-contract-boundary.md new file mode 100644 index 0000000..c30e0b1 --- /dev/null +++ b/docs/implements/2026-08-07-rerank-gate-contract-boundary.md @@ -0,0 +1,24 @@ +# 재정렬 후보 불변 계약과 결합 신뢰도 게이트 계약의 분리 + +- **티켓**: S15P11A705-402 +- **날짜**: 2026-08-07 +- **성격**: 문서·테스트 경계 명시. 런타임 코드는 바꾸지 않는다. + +## 배경 + +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4.5는 착수 시 필요한 검증 중 하나로 다음을 명시했다. + +> 기존 계약 테스트(§4.1에서 인용한 "정렬은 후보를 추가·제거하면 안 된다")는 재정렬 로직의 계약이지 이 새 게이트의 계약이 아니라는 것을 별도로 명시해야 한다 — 헷갈리면 안 되는 지점이다. + +`tests/test_search_rerank.py`가 고정하는 다섯 계약 중 ②("같은 요청·같은 limit에서 on/off의 후보 Record id 집합이 같다")는 재정렬(`_rerank_by_keyword`)의 것이다. 결합 신뢰도 게이트(S15P11A705-400)는 back 레포에 구현됐고 정확히 그 반대 성질 — S1 신호만 있고 유사도가 낮은 후보를 **의도적으로 제거한다** — 을 가진다. 이 파일만 읽으면 "재정렬은 후보를 안 지운다"는 규칙이 게이트에도 적용돼야 한다는 오독이 생긴다. + +## 변경 + +- `tests/test_search_rerank.py`의 모듈 docstring에 "이 파일이 고정하지 않는 것" 절을 추가했다. 다섯 계약이 재정렬 자신의 것이며, 결합 신뢰도 게이트는 별도 저장소(`back`)의 별도 마지막 단계(BD-52)라는 것을 명시한다. +- 새 테스트 `test_rerank_never_gates_a_weak_unsupported_candidate`를 추가했다. 게이트라면 제외했을 모양의 후보(기존 컷은 통과하지만 게이트 채택 임계값보다 낮은 유사도, S2·S3 신호 없음)를 재정렬에 넣고, 그 후보가 순서만 밀릴 뿐 결과에서 사라지지 않는 것을 확인한다. + +## 검증 + +- `pytest tests/test_search_rerank.py` — 신규 1건 포함 전체 통과. +- `ruff check .` · `pytest --cov` 전체 · `tools/check_coverage_gate.py` — line 94.64%·branch 85.00%, 둘 다 통과. +- `pytest tests/test_docs_index.py` — 이 작업 도중 `docs/implements/README.md`의 두 색인 표(개별 리포트·구현·산출 전수) 모두에 S15P11A705-399 리포트 행이 빠져 있던 것을 발견해 함께 고쳤다(원래 이 티켓의 범위는 아니었으나, 색인 검사가 이 티켓의 변경으로 인해 실행되면서 잡았다). diff --git a/docs/implements/README.md b/docs/implements/README.md index 4d56270..4039a4e 100644 --- a/docs/implements/README.md +++ b/docs/implements/README.md @@ -61,6 +61,7 @@ | I57 | [2026-08-07-preset-label-realign-gate.md](2026-08-07-preset-label-realign-gate.md) | 검증 | 프리셋 표시명 정합 회복과 게이트 재검증 — 표시명 명사형 통일(-292)이 YAML 정본에만 반영되고 두 DB에는 옛 표시명이 남아 **프리셋 임베딩·측정 자산이 정본과 어긋나 있던 결함**을 스냅샷 재적재로 해소. 조작 범위를 md5 4종으로 대조해 Context 임베딩·판정 상태·context_keyword 83행 불변 확인(판정 불변). 새 임베딩 격자에서 **채택값(binary·floor 0.35·w 0.05·top_k 3) 유지** — 퇴행 없이 단어형 개선(1위 0.8636→0.8788·3위내 0.9394→0.9545·MRR 0.9015→0.9121). **변화: 문장형 개선(MRR 0.9028→0.9167)이 사라지고 floor 0.40 구간으로 이동** — 재정렬의 이득이 단어형에 집중. 경계 감도 0.345~0.36 안정하나 기준선 0.35 인접 쌍 10건 중 9건이 임베딩 흔들림 폭 이내라는 한계 기록. 결정성 2회 일치. **게이트 5기준 전량 재통과**(3 phase 재실행 — judge가 세 결과를 함께 읽어 부분 재실행 불성립). keyword_matrix 포트 가드를 시연 DB에서 스냅샷으로 정정 동반 (검색 고도화 트랙 공통 · 스냅샷 DB) | | I58 | [2026-08-07-gate-threshold-remeasure.md](2026-08-07-gate-threshold-remeasure.md) | 검증 | 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4) threshold 후보 0.35 오프라인 재측정 — `SEARCH_KEYWORD_RERANK_FLOOR`에서 값만 가져온 것이 아니라 이 용도(S1 단독·저유사도 결과 숨김)로 별도로 다시 쟀다. `gate_sweep.py`가 기존 행렬(문장형 정답 12·단어형 정답 66·진단프로브 22·무관 60·타인소유 96건)에 게이트 규칙을 적용해, threshold 0.35에서 정답 손실 0을 유지하며 무관 노출이 크게 준다(단어무관 23/45·타인소유 55/96 신규 침묵)는 것을 확인했다. 최초 실행은 `recall_probe.json`의 최상위 `user_id` 처리 버그로 프로브 손실 6건을 오탐했고, 고친 뒤 0.35 채택을 권고했다 (S15P11A705-401 · 스냅샷 없음 · DB·GMS 호출 0회) | | I59 | [2026-08-07-search-keyword-matched-field.md](2026-08-07-search-keyword-matched-field.md) | 구현 | 검색 응답에 `keywordMatched: bool` 필드 추가 — `_rerank_by_keyword`가 이미 계산하던 Preset 매치 여부를 정렬에만 쓰고 버리던 것을 `(list, matched_ids)` 튜플로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 값 그대로 유지(정렬 점수 비노출 계약 불변), 재정렬이 생략되는 모든 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 (S15P11A705-399) | +| I60 | [2026-08-07-rerank-gate-contract-boundary.md](2026-08-07-rerank-gate-contract-boundary.md) | 구현 | 재정렬 후보 불변 계약(②)과 결합 신뢰도 게이트(back 레포 S15P11A705-400)의 계약을 분리 명시 — `test_search_rerank.py` docstring에 "이 파일이 고정하지 않는 것" 절을 추가하고, 게이트라면 제외했을 모양의 후보도 재정렬은 지우지 않는다는 것을 `test_rerank_never_gates_a_weak_unsupported_candidate`로 실행 고정 (S15P11A705-402) | > **유형**: 구현(무엇을 만들었나) / 검증(어떻게 검증했나) / 감사(티켓·문서가 실물과 맞는가). 검증 성격 문서가 늘면 이 컬럼이 분류 기준이 된다. > **분리 트리거**: 리포트가 15개를 넘고 검증 유형이 절반 이상이면 `verification/` 분리를 검토한다. @@ -124,6 +125,7 @@ | 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/) | -| I59 | 검색 응답 `keywordMatched: bool` 필드 `app/schema/search.py`·`app/service/search_service.py`(`_rerank_by_keyword` 반환을 `(list, matched_ids)` 튜플화) — 이미 계산되던 Preset 매치 여부를 응답까지 배선한다. `similarity`는 원래 코사인 값 유지, 재정렬 생략 경로는 빈 집합이라 자연히 `False`. 결합 신뢰도 게이트(`S15P11A705-400`)가 쓸 S3 신호 준비 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md) | +| I59 | 검색 응답에 `keywordMatched` 필드 추가(S15P11A705-399) — `_rerank_by_keyword`(I56)가 이미 계산하지만 정렬에만 쓰고 버리던 Preset 매치 여부를 `tuple[list, set[int]]`로 바꿔 응답까지 싣는다. `similarity`는 원래 코사인 그대로 두어 정렬 점수 비노출 계약(③)을 지켰다. 재정렬이 생략되는 모든 경로(off·오류·후보 없음)에서는 빈 집합이라 필드가 자연히 `False`다. 결합 신뢰도 게이트(중앙 조정 세션 `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4, back 레포 S15P11A705-400)가 쓸 S3 신호를 준비하는 작업이다 | [필드 추가 리포트](2026-08-07-search-keyword-matched-field.md), [I56](2026-08-06-rerank-adoption.md), [test_search_rerank.py](../../tests/test_search_rerank.py) | +| I60 | 재정렬 후보 불변 계약과 게이트 계약 분리(S15P11A705-402) — `test_search_rerank.py`가 고정하는 다섯 계약(특히 ② 후보 불변)이 재정렬 자신의 것이며 결합 신뢰도 게이트(back 레포 S15P11A705-400, BD-52)의 계약이 아님을 모듈 docstring에 명시하고, `test_rerank_never_gates_a_weak_unsupported_candidate`로 "게이트라면 지웠을 후보도 재정렬은 지우지 않는다"를 실행 고정. 런타임 코드 변경 없음 | [경계 리포트](2026-08-07-rerank-gate-contract-boundary.md), [I59](2026-08-07-search-keyword-matched-field.md), `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4.5 | > I6·I7·I8은 백엔드 아티팩트라 **back 레포** `docs/ai/implements`에 있습니다. diff --git a/tests/test_search_rerank.py b/tests/test_search_rerank.py index a504515..b50e258 100644 --- a/tests/test_search_rerank.py +++ b/tests/test_search_rerank.py @@ -13,6 +13,24 @@ DB 는 가짜 커넥션으로 대체한다 — 여기서 재는 것은 재정렬 경로이지 SQL 이 아니다. 같은 계약의 오프라인 판은 tests/test_search_fusion.py §9(rerank 픽스처)에 있다. + +## 이 파일이 고정하지 않는 것 (S15P11A705-402) + +위 다섯은 **재정렬 자신의 계약**이다 — 순서를 바꾸되 후보를 추가·제거하지 않는다. +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4가 제안하고 +`back` 레포가 구현한 **결합 신뢰도 게이트**(S15P11A705-400)는 정반대 성질을 가진다 — +S1(벡터) 신호 하나뿐이고 유사도가 낮은 후보를 **의도적으로 제거한다.** + +두 계약을 같은 파일·같은 절로 두면 "재정렬은 후보를 안 지운다"는 ②가 게이트에도 +적용돼야 한다는 오독이 생긴다. **그렇지 않다** — 게이트는 재정렬 *이후*, back 이 +문자열 병합 직후에 거는 별도의 마지막 단계이고(BD-52, back 레포), 이 파일이 재는 +`_rerank_by_keyword`·`rerank()`는 그 단계를 전혀 모른다. `keywordMatched` 필드 +(S15P11A705-399, 아래 절)는 그 게이트가 쓸 S3 신호를 실어 보내는 준비 작업일 뿐, +게이트 판정 자체는 이 레포가 아니라 `back`에 있다. + +아래 `test_rerank_never_gates_a_weak_unsupported_candidate`가 이 경계를 실행으로 +고정한다 — 게이트라면 제외했을 조건(신호 없음·낮은 유사도)의 후보도 재정렬은 +지우지 않는다. """ from __future__ import annotations @@ -303,6 +321,48 @@ async def test_keyword_matched_is_false_for_all_when_no_candidate_preset( assert all(r["keywordMatched"] is False for r in result) +# ── 재정렬 vs 게이트 경계 (S15P11A705-402) ───────────────────────────────── +# +# 위 다섯 계약과 keywordMatched 필드는 전부 **재정렬**의 것이다. 결합 신뢰도 +# 게이트(S15P11A705-400)는 back 레포에 있고 이 레포가 판정하지 않는다 — 아래 +# 테스트는 그 경계를 "재정렬은 게이트라면 지웠을 후보도 지우지 않는다"로 고정한다. + + +@pytest.mark.anyio +async def test_rerank_never_gates_a_weak_unsupported_candidate(monkeypatch): + """게이트라면 뺐을 모양의 후보(신호 없음·기존 컷은 통과·낮은 유사도)도 재정렬은 남긴다. + + 104는 기존 컷(`τ_word=0.24`·`r=0.6`)은 통과하지만(`0.31 ≥ 0.30`) 결합 신뢰도 + 게이트의 채택 임계값(0.35, ai 레포 S15P11A705-401)보다는 낮고 S2(문자열)·S3 + (키워드) 어느 신호도 없다 — back의 게이트였다면 정확히 이 모양의 후보를 + 제외한다. 이 레포의 재정렬은 그 판단 자체를 하지 않으므로 104는 순서만 + 맨 뒤로 밀릴 뿐 후보에서 사라지지 않는다(계약 ②는 여기서도 성립한다). + """ + weak_rows = [ + {"record_id": 101, "context_id": 11, "similarity": 0.50}, + {"record_id": 102, "context_id": 12, "similarity": 0.48}, + {"record_id": 103, "context_id": 13, "similarity": 0.40}, + {"record_id": 104, "context_id": 14, "similarity": 0.31}, + ] + + async def _rows(conn, user_id, profile, embedding, limit): + return [dict(r) for r in weak_rows] + + monkeypatch.setattr( + "app.service.search_service.context_embedding_repo.search", _rows + ) + settings = _settings(monkeypatch, SEARCH_KEYWORD_RERANK_ENABLED="true") + service, _ = _service( + monkeypatch, settings, presets=ALIGNED, keyword_rows=MATCH_102 + ) + result = await _search(service) + + assert {r["recordId"] for r in result} == {101, 102, 103, 104} + weakest = next(r for r in result if r["recordId"] == 104) + assert weakest["similarity"] == 0.31 + assert weakest["keywordMatched"] is False + + def test_candidate_rule_matches_the_harness(monkeypatch): """floor 를 top_k 보다 먼저 걸고 동점은 preset_id 오름차순 — 하네스와 같은 규칙.