o2o-negosium-original/schedules/anchoring/docs/개발용.md
민헌 c0d6723fe4 feat(anchoring): 설정 파일 환경 분리 — APP_ENV 로 config.{local,dev,prod}.toml 선택
- config.py: APP_ENV(기본 local) 기반 config.{APP_ENV}.toml 로드 — backend/negodata 와 동일 관례
- dev/prod 는 파일 부재 시 기동 즉시 중단(FileNotFoundError) — 오타·미배치 상태로
  코드 기본값(로컬 DB)에 붙는 무증상 사고 방지. local 만 파일 없이 기본값 허용(개발 편의)
- main 기동 로그에 APP_ENV·설정 파일 명시
- 루트 compose: anchoring 서비스에 APP_ENV=local + config.local.toml 마운트
- config.toml.example 을 3환경 공용 템플릿으로 갱신, .gitignore 에 config.*.toml
  (env별 실파일은 gitignore — dev/prod 는 배포 시 CHANGE_ME 채움)
- TODO.md 삭제(주요 과제 전부 종결) + README·개발용.md 참조 정리
- 검증: 모듈 20 테스트 통과, APP_ENV=staging fail-fast(exit 1)·local dry-run 기동 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:41:42 +09:00

51 KiB
Raw Blame History

앵커링 시스템 구현 스펙 (개발용)

문서 성격: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본). 규범 언어: MUST = 반드시 준수, MUST NOT = 금지, SHOULD = 권장, MAY = 선택. 스택: Python(asyncio) + PostgreSQL(SQLAlchemy async / SQL 수동 적용, Alembic 없음) + Redis + APScheduler — schedules/anchoring 자립 컨테이너(backend 내장 아님, FastAPI 미사용). 버전: v1.2 (2026-07-02 확정) — 정책 배경은 기획용.md, 흐름 해설은 워크플로우.md, 타 팀(negodata) 적용 명세는 인수인계.md 참조.


목차

  1. 확정 결정 요약
  2. 정적 기본 테이블
  3. 상수 정의
  4. 도메인 규칙
  5. 아키텍처
  6. DB 스키마
  7. Redis 캐시 규약
  8. 배치 잡 명세
  9. 견적 생성·협상 플로우
  10. 참조 구현
  11. 검증 벡터 (Golden Tests)
  12. 금지·봉인 사항
  13. 선행·연계 작업

1. 확정 결정 요약

항목 결정
역할 분담 schedules/anchoring 자립 모듈 = 정적 테이블·rate 조회(reader)·조정 배치·Redis 규약·DDL 소유, 독립 컨테이너로 자체 스케줄 실행 / negodata = 세션 생성 시 reader 로 rate 조회 → 앵커가·rate 박제 (인수인계, §9.1) / backend = 협상 채팅(박제값 소비 + 마지막 제시가 기록, anchoring 모듈 무의존) (§9.2) / agent = 변경 없음(앵커 비노출 — 정보 비대칭 전략)
소유·수정 범위 직접 수정 가능 = backend·frontend·schedules(우리 모듈). negodata·agent는 인수인계 문서로 전달 → 담당 개발자가 적용
기본 테이블 서비스 시작 시 메모리 로드되는 불변 정적 테이블 (src/anchoring/resources/anchoring_base.json, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스
가격구간 자릿수 계단식 사다리(46칸) — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = 정확히 1억, target_price > 1억은 전부 마지막 인덱스(45) 로 클램프
멀티테넌시 앵커링 값은 회사(company)별로 독립 — 칸 키에 company_id(uuid) 포함
표본 전용 테이블 없음. 종료된 재협상 세션(negotiation.sessions)의 종료 후 불변 컬럼(anchoring_price, anchoring_value, last_offer_price, bid_price, status)에서 배치 시점에 파생 판정한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다
표본 기준 "가격 흔적": 협력사가 가격을 한 번이라도 써낸(last_offer_price 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외
앵커 비노출 agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작
조정 이력 저장 append-only 조정 이력 anchoring.adjustments 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시
소비 경계 sessions.used_by_adjustment_id 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = 한 트랜잭션
평가 트리거 격주 토요일 00:00 (KST), 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가
평가 방식 누적 전량 평가: 미처리 유효 표본 전부(n건)로 r = 성공/n 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월
앵커링가 반올림 1원 단위 내림(floor) — 정수 연산만 사용
값 표현 앵커링 값은 정수 천분율(‰) 로 저장·계산 (부동소수점 산술 금지)
코드값 프로젝트 컨벤션: SMALLINT 1-based 코드 + 앱 enum 매핑, DB CHECK/FK/ENUM 없음

2. 정적 기본 테이블

서비스 시작 시 메모리에 로드되는 불변 리스트. DB에 저장하지 않으며, 런타임에 절대 수정하지 않는다 (MUST NOT).

파일: src/anchoring/resources/anchoring_base.json (리포에 커밋, 46행). 키는 프로젝트 컨벤션대로 snake_case. 구간은 자릿수 계단식 사다리:

구간 폭 칸 수
0 ~ 1,000 (한 칸으로 통일) 1
1,000 ~ 1만 1,000원 9
1만 ~ 10만 1만 9
10만 ~ 100만 10만 9
100만 ~ 1,000만 100만 9
1,000만 ~ 1억 1,000만 9
합계 폭 = 자릿수 시작값(구간 상한의 10%) 46
[
  { "idx": 1,  "upper_bound": 1000,      "anchoring_value": 0.01 },
  { "idx": 2,  "upper_bound": 2000,      "anchoring_value": 0.01 },
  ...
  { "idx": 10, "upper_bound": 10000,     "anchoring_value": 0.01 },
  { "idx": 11, "upper_bound": 20000,     "anchoring_value": 0.01 },
  ...
  { "idx": 46, "upper_bound": 100000000, "anchoring_value": 0.01 }
]

각 칸은 사람이 부르는 가격대와 일치한다 — idx 13 = "3만 원대"([30,000, 40,000)). 1억 초과 가격은 전부 마지막 인덱스로 클램프된다(§2.1). 사다리의 단일 소스는 constants.UPPER_BOUNDS(생성식)이며, json 은 기동 시 이와 대조 검증된다.

2.1 매핑 규약 (MUST)

항목 규약
구간 범위 idx k의 구간 = [이전 upper_bound, upper_bound) 좌폐우개 (idx 1 은 [0, 1,000))
경계값 소속 target_price가 정확히 upper_bound와 같으면 다음 idx 소속. 예: 30,000원 → "3만 원대" 칸(idx 13)
내부 인덱스 변환 price_range_index = idx − 1 = bisect_right(UPPER_BOUNDS, price) (0-기반). DB·Redis·코드 내부는 price_range_index 사용
상한 클램프 target_price ≥ 90,000,000 → 마지막 구간(idx 46, price_range_index 45). 1억 초과도 예외 없이 마지막 인덱스
시작값 칸의 시작 앵커링 값 = 해당 idx의 anchoring_value 천분율 변환 정수: int(round(anchoring_value * 1000)). 현재 전 구간 10‰
기동 검증 로드 시 46행·idx 연속(1..46)·upper_bound == constants.UPPER_BOUNDS[i](사다리 대조)·0.01 ≤ anchoring_value ≤ 0.20 검증, 실패 시 기동 중단 (§13)
  • 시작값은 정적 테이블에서만 읽는다. 코드에 0.01/10 하드코딩 MUST NOT (테이블이 유일한 소스).
  • anchoring_value는 회사 무관 공통. 회사별 차이는 조정 이력의 누적에서만 발생한다.

3. 상수 정의

모든 비율은 정수 천분율(permille). 10‰ = 1%.

# src/anchoring/constants.py

ANCHORING_VALUE_MIN = 10          # 하한 1%
ANCHORING_VALUE_MAX = 200         # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드

# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
ADJUSTMENT_STEP = {
    1: 20,   # 유통(DISTRIBUTION)  ±2%
    2: 10,   # 제조(MANUFACTURE)   ±1%
    3: 15,   # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
}

SAMPLE_THRESHOLD = 10         # 평가 최소 유효 표본 수 (미만이면 스킵·이월)

# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
PRICE_MAX          = 100_000_000  # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
UPPER_BOUNDS       = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000)  # 생성식으로 정의, 46개
PRICE_RANGE_COUNT      = 46
PRICE_RANGE_INDEX_MAX  = 45       # 0-기반 구간 인덱스 상한

EVAL_WEEK_PARITY   = 0        # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
                              # 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)

MARK_EXCLUDED = 0             # sessions.used_by_adjustment_id 제외 확정 마킹값

CACHE_TTL_SECONDS = 7 * 24 * 3600   # Redis 키 TTL(§7) — stale 잔존 방지 보조
REDIS_SOCKET_TIMEOUT = 0.3          # 행(hang) 방지 — 초과 시 DB 폴백

앱 enum — backend 는 anchoring 판정을 하지 않으므로(무의존) enum 은 모듈 내부(constants.py)에 둔다 (프로젝트 컨벤션 — plain Enum, 1-based, 대상 컬럼 docstring):

class SupplierType(Enum):
    """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
    (negodata SupplierType 과 동일 코드)"""
    NONE = 0          # 미지정 — 앵커링 칸 구성 불가(집계 제외)
    DISTRIBUTION = 1  # 유통
    MANUFACTURE = 2   # 제조
    SOLE_AGENCY = 3   # 총판

class AnchoringSampleType(Enum):
    """앵커링 표본 판정 결과(파생값 — DB 에 저장하지 않음, 평가 로직·로그용).
    기준 = "가격 흔적": 가격을 써낸 협상만 표본."""
    BID_SUCCESS = 1   # 정상종료 + bid ≤ 박제 앵커
    BID_FAIL = 2      # 가격 흔적 있으나 성공 아님 (앵커 초과 합의 / 결렬 / 가격 쓰고 이탈·만료)
    EXCLUDED = 3      # 가격 흔적 없음 / 앵커 박제 없음 / 유형 미지정
  • 상수 변경은 정책 재확정 사안이다. 코드에서 임의 조정 MUST NOT.
  • 앵커링 값을 float으로 저장·연산 MUST NOT. 모든 산술은 정수로 수행한다 (성공률 비교도 §10처럼 정수 비교).

4. 도메인 규칙

4.1 칸(cell) 식별

칸 = (company_id, supplier_type, price_range_index) 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.

price_range_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
  • price_range_index 산출 기준 가격은 목표가(target_price) 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
  • 칸 해석 소스: company_id = partner.items.company_id (세션의 item 소유 회사 = 갑), supplier_type = quotation.quotations.supplier_type (재협상 1:1 견적에 기록됨).
  • 같은 구간·유형이라도 회사가 다르면 서로 다른 칸. 회사 간 표본·값 공유 MUST NOT.
  • 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
  • supplier_type ∉ {1,2,3} 이거나 company_id 미해석 세션은 칸을 구성할 수 없다 → 가격 산출은 정적 테이블 시작값으로 동작(§9), 집계에서는 제외(§4.3).

4.2 앵커링가 계산

anchor_price = target_price × (1000 − anchoring_value) // 1000
  • target_price가 정수(원)이므로 위 식은 정수 연산만으로 정확한 내림을 보장한다.
  • 부동소수점 곱셈 경유 MUST NOT (int(price * 0.99), round(price * 0.99) 형태 금지).
  • 결과는 항상 1원 단위 정수.

4.3 표본 판정 (배치 시점 파생 — "가격 흔적" 기준)

표본 = 종료된 재협상 세션 중 협력사가 가격을 한 번이라도 써낸 것. 전용 테이블 없이, 배치가 아래 종료 후 불변 입력에서 판정을 파생한다.

한 줄 요약: "가격을 써낸 협상만 세고 — 앵커 이하로 합의됐으면 성공, 나머지는 전부 실패."

판정 입력:

컬럼 의미 기록 시점
sessions.anchoring_price 제안 당시 앵커링가 (판정 기준) negodata 세션 생성 시 1회 박제 (§9.1)
sessions.anchoring_value 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) 동상
sessions.last_offer_price 협력사 마지막 제시가 = 가격 흔적 (NULL = 가격을 써낸 적 없음) backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변
sessions.status / bid_price 종료 상태 / 확정 투찰가 세션 종료 시 확정

판정 대상: qt_type = 1(재협상) AND status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED} AND deleted = false.

판정 조건 유효 표본 성공
BID_SUCCESS status=DONE AND bid_price ≤ anchoring_price O O
BID_FAIL 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) O X
EXCLUDED last_offer_price IS NULL(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 X —
  • "유효 표본" = EXCLUDED가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
  • 왜 실패에 결렬·이탈이 반드시 포함돼야 하나: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(check_price_match) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
  • 판정 입력 컬럼은 종료 후 절대 수정 금지 (MUST NOT — §12). last_offer_price 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.

4.4 평가 산식 (누적 전량 평가)

배치 시점에 칸별로 수행한다.

pending = 해당 칸의 미처리(used_by_adjustment_id IS NULL) 유효 표본 전부
n = |pending|

n < 10  →  평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
n ≥ 10  →  r = (pending 중 BID_SUCCESS 건수) / n

        ┌ +δ(p)   if r ≥ 0.60
delta = ┤  0      if 0.30 ≤ r < 0.60
        └ −δ(p)   if r < 0.30

anchoring_value_after = clamp(anchoring_value_before + delta, 10, 200)
→ 한 트랜잭션으로:
   ① anchoring.adjustments INSERT (n, success, before/after, used_session_ids 박제)
   ② 소비 세션 UPDATE sessions SET used_by_adjustment_id = <조정 id>
      WHERE session_id IN (...) AND used_by_adjustment_id IS NULL   ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
  • 분모는 항상 실제 누적 건수 n (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
  • delta = 0이어도, clamp에 막혀 값이 안 변해도 조정 레코드는 반드시 INSERT하고 표본을 소비(마킹)한다 (MUST).
  • "표본 소비" = 마킹. 물리 삭제 없음. EXCLUDED·칸 구성 불가 세션은 평가와 무관하게 used_by_adjustment_id = 0으로 일괄 마킹해 재스캔을 방지한다.
  • 한 칸은 한 배치에서 최대 1회 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).

4.5 현재 앵커링 값 조회

값은 저장된 단일 상태가 아니라 조정 이력의 최신 행이다.

rate = (칸의 최신 anchoring.adjustments 행).anchoring_value_after
       없으면 → 정적 테이블 시작값 (§2.1)
  • 재현성: 조정 행에 박제된 used_session_ids(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. 조정 이력은 유일 진실 원천이며 보호 대상이다 (백업 정책 적용 MUST).
  • 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 used_session_ids를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 MUST NOT (배치 시각 의존이므로 불가능).
  • 알려진 완화: sessions 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.

5. 아키텍처

[anchoring 서비스 기동 — schedules/anchoring 독립 컨테이너]
      정적 기본 테이블 메모리 로드·검증 (불변, §2) ── Redis 클라이언트 init ── APScheduler 기동

[견적/세션 생성 — negodata, 인수인계 §9.1]
      │  칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
      │  → 세션 INSERT 에 anchoring_price + anchoring_value 박제 (재생성 상속 폐지)
      ▼
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
      │  박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
      │  가격 입력 턴마다 last_offer_price 갱신 (가격 흔적)
      ▼
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
      │
      │  격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
      │  → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
      ▼
anchoring.adjustments ────────── 진실 원천 (INSERT only, used_session_ids·값 변화 박제)
      │
      │  배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
      ▼
Redis  anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TTL 7일
      │
      │  GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
      ▼
[다음 견적/세션 생성]  조정된 rate 로 앵커가 산출
  • 조정 이력 테이블에 UPDATE / DELETE MUST NOT.
  • 배치가 sessions에 쓰는 것은 used_by_adjustment_id 단 하나 — 다른 컬럼 수정 MUST NOT.
  • 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
  • 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
  • Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).

6. DB 스키마

프로젝트 컨벤션 준수: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 postgres-init/05-anchoring-schema.sql 한 파일(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관). sessions 앵커링 컬럼은 01-schema*.sql·04-alter*.sql 소관. 소유 서비스는 여전히 이 모듈이다.

네이밍 결정 — 기존 코드베이스 용어와 통일:

개념 명칭 이유
조정 이력 테이블 anchoring.adjustments 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현
협력사 유형 supplier_type 기존 quotations.supplier_type과 용어 통일
가격구간 price_range_index 가격구간임을 명시 (코드 내부 변수는 price_range_index)
표본 수 sample_count "협상 결과 n건" — 정책 문서 용어
값 변화 anchoring_value_before / anchoring_value_after sessions.anchoring_value와 계열 통일 (‰)
소비 창 used_session_ids "이 조정이 소비한 세션"임을 명시
생성 시각 created_at 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각)
소비 마킹 sessions.used_by_adjustment_id 조정 테이블명과 정합

6.1 조정 이력 (신설 — 유일한 새 테이블)

CREATE SCHEMA IF NOT EXISTS anchoring;

-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.adjustments (
    id                   BIGSERIAL   PRIMARY KEY,
    company_id           uuid        NOT NULL,   -- 테넌트(partner.items.company_id 유래)
    supplier_type        SMALLINT    NOT NULL,   -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
    price_range_index  INTEGER     NOT NULL,   -- 가격구간 0..45 자릿수 사다리 (앱 보장)
    sample_count           INTEGER     NOT NULL,   -- 유효 표본 수 n (>=10, 앱 보장)
    success_count        INTEGER     NOT NULL,   -- n 중 성공(BID_SUCCESS) 건수
    anchoring_value_before   SMALLINT    NOT NULL,   -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
    anchoring_value_after    SMALLINT    NOT NULL,   -- 조정 후 값(‰), clamp [10,200] 앱 보장
    used_session_ids JSONB       NOT NULL,   -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
    created_at           TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_adjustments_cell
    ON anchoring.adjustments (company_id, supplier_type, price_range_index, id DESC);

6.2 sessions 확장 (기존 테이블 ALTER)

ALTER TABLE negotiation.sessions
  ADD COLUMN IF NOT EXISTS anchoring_value    SMALLINT    NULL,  -- 제안 당시 rate(‰) 박제
  ADD COLUMN IF NOT EXISTS last_offer_price      BIGINT      NULL,  -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
  ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT      NULL;  -- NULL=미처리 0=제외확정 >0=소비한 조정 id

-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST —
-- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장)
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
    ON negotiation.sessions (status)
    WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1;

6.3 조회용 뷰 (파생 — 상태 없음)

회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 뷰로 제공한다(진실 원천은 adjustments 그대로):

anchoring.value_history   -- 값 변경 이력 리스트업: 이전 값(anchoring_value_before)→새 값 + value_change·success_rate·created_at
anchoring.current_values  -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
  • 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
  • "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.

주의사항:

  • sessions.anchoring_price는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).
  • 신규 DB 구축 시 적용 순서: postgres-init/01~05 (IF NOT EXISTS 라 재적용 안전). sessions 3컬럼은 backend ORM 이 참조하므로 postgres-init/01-schema*.sql·04-alter*.sql 에도 반영돼 있다(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유.
  • backend 모델(models.py)에는 sessions 3컬럼만 추가한다 — adjustments 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).

7. Redis 캐시 규약

항목 규약
키 anchor:{company_id}:{supplier_type}:{price_range_index} — supplier_type 은 SMALLINT 코드값. 예: anchor:0b0e…:1:10
값 정수 천분율 문자열. 예: "30"
TTL 7일 (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8)
캐시 미스 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → SET NX(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지
갱신 배치가 평가한 칸 SET + 매주 토 잡 실행 시(격주 게이트 무관) 조정 이력 보유 칸 전체 re-SET (§8 절차 0.5)
장애 내성 Redis 에러 시 GET→None 취급(DB 폴백), SET 은 로그만 남기고 무시 (MUST — 견적 생성·배치를 Redis 가 막으면 안 됨). socket timeout 0.2~0.5초 설정 MUST(행 방지)
값 검증 GET 값이 정책 범위 [10, 200] 밖이면 오염(외부 SET 등)으로 간주 — WARN 후 미스 취급(DB 폴백 + 재적재로 자가 교정). 캐시 값을 검증 없이 제안가에 쓰지 않는다 (MUST)
  • 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
  • ⚠️ stale 키는 "미스"가 나지 않는다: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
  • 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
  • 클라이언트: redis.asyncio — 사용 주체는 anchoring 서비스(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, negodata 도 쓰지 않는다(2026-07-04 적용된 이식판 reader 는 current_values 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 config.{APP_ENV}.toml(local/dev/prod, 기본 local) + REDIS_HOST/PORT/PASSWORD env 오버라이드.
  • 보안: 무인증 Redis 를 외부 네트워크에 노출 MUST NOT — 오염된 rate 는 실제 제안가를 왜곡한다. compose(루트 docker-compose.yml)는 포트를 127.0.0.1 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다.

8. 배치 잡 명세

  • 러너: schedules/anchoring 자립 컨테이너의 APScheduler(AsyncIOScheduler, Asia/Seoul) — 자체 Dockerfile·config.{APP_ENV}.toml 보유(APP_ENV 로 local/dev/prod 선택, compose 는 루트 docker-compose.yml 에 통합), backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(coalesce=True, max_instances=1, misfire_grace_time=3600), 진입점은 python -m anchoring.main(상주) / python -m anchoring.main --once(수동 1회, 게이트 무시) / --once --dry-run(예행 — 아래 dry-run 모드). 플래그는 argparse 로 검증한다 — --dry-run 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 기동 전 즉시 에러(종료코드 2): 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. --once 는 종료 상태가 done/skipped/dry_run 이 아니면(부분 실패 포함) 종료코드 1 로 끝난다(cron·수동 실행 실패 감지). config.{env}.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. dev/prod 는 파일 부재 시 기동 즉시 중단(local 만 기본값 허용).
  • 스케줄: 매주 토 00:00 KST 트리거(CronTrigger(day_of_week="sat", hour=0, minute=0)) + 잡 내부에서 ISO 주차 % 2 == EVAL_WEEK_PARITY 격주 게이트 (기준 패리티는 상수 고정 MUST).
  • 멱등성: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 AND used_by_adjustment_id IS NULL 조건 + rowcount = n 검증(불일치 시 전체 롤백) MUST 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
  • 원자성: 조정 INSERT 와 세션 마킹은 같은 DB 세션의 한 트랜잭션에서 실행한다(MUST). 모듈은 자체 async 엔진(session_scope)을 쓰므로 자연 충족된다. (참고: backend 의 DB_SESSION_MNG.execute_lambda_run은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
절차 (run_evaluation_batch(force=False)):
0.   force 아니고 격주 게이트 미충족 → 절차 0.5 만 수행 후 종료
0.5. 캐시 정합(매주, 게이트 무관): Redis ping 확인 후 조정 이력 보유 칸 전체의 최신 rate 를 일괄 re-SET
     (Redis 미가용이면 WARN 후 즉시 건너뜀 — 셀마다 timeout 을 태우며 지연되지 않게)
     (SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조):
   sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false)
   WHERE s.used_by_adjustment_id IS NULL AND s.deleted = false
     AND s.qt_type = 1 AND s.status IN (3, 4, 5)
2. 세션별 파생 판정(§4.3):
   - EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
     → used_by_adjustment_id = 0 일괄 마킹 (재스캔 방지)
   - 유효 표본 → 칸별 그룹 적재
   - 박제 정합 감시: rate 가 박제된 세션에 대해 calc_anchoring_price(tp, anchoring_value)(§4.2 정수식)와
     박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
     — negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
     검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
   anchoring_value_before = 최신 조정 anchoring_value_after (없으면 정적 테이블 시작값)
   anchoring_value_after  = evaluate_samples(...)              # §4.4 / §10
   ① anchoring.adjustments INSERT (used_session_ids 박제)
   ② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchoring_value_after   (best effort, TTL 7일)
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / clamp 포화(조정치가 상·하한 밖으로 나가 잘린 칸 —
   경계값에서의 단순 '유지'는 세지 않음) / 이월 칸 수 / 제외 마킹 건수
   + 가격 제시율(종료 재협상 세션 중 last_offer_price 보유 비율) — 0% 면 WARN
     (backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)

로그 규약 (운영 추적):

  • 출력 = stdout(컨테이너 json-file 드라이버, 루트 compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 항상 KST(+0900).

  • 모든 배치 라인에 [batch {run_id}] 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 company= type= price_range= key=value 형식 → 회사별 grep(grep company=<uuid>).

  • 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → 칸별 조정 상세(n= 성공= before‰→after‰ adj_id= — DB 행과 교차 확인) → 회사요약(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약.

  • 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / failed_cells > 0 이면 종료 요약을 WARNING 으로 승격(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.

  • 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).

  • dry-run 모드 (--once --dry-run / run_evaluation_batch(dry_run=True)): 절차 0.5 re-SET·제외 마킹·조정 INSERT·캐시 SET 을 전부 건너뛰고, 판정 결과·예상 조정(조정예정 라인, adj_id=None)·제외 예정 건수만 로그로 남긴다(종료 status dry_run). 상태를 소비하지 않으므로 직후 실제 실행 결과와 동일하다 — 첫 운영 실행(레거시 세션 전량 판정) 전에 규모를 확인하는 예행 용도.

  • 수동·테스트 실행은 run_evaluation_batch(company_ids=[...]) 로 대상 회사를 한정할 수 있다 — 공유 DB 에서 다른 회사의 미처리 세션을 소비하지 않는다(테스트 스위트가 사용).

  • INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.

  • n < 10 칸의 유효 표본은 마킹하지 않는다 — 그것이 이월이다.

  • 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.

  • 별도 batch_runs 테이블 없음 — 조정 이력이 곧 실행 기록이며, 회차 요약은 LOG로 남긴다.

  • 운영 런북: 토 00:00 에 서비스가 내려가 있었다면(misfire_grace 1h 초과) 그 회차는 스킵되고 패리티 게이트 때문에 2주 뒤 실행된다. 누적 평가라 데이터 손실은 없으나, 재기동 후 --once 수동 1회 실행으로 즉시 따라잡을 수 있다.


9. 견적 생성·협상 플로우 (역할 분담)

앵커가의 산출·박제 주체는 negodata(견적 생성 측) 이고, backend(협상 채팅)는 박제값의 소비자다. negodata 변경은 직접 수정하지 않고 인수인계.md로 전달한다. agent 는 변경하지 않는다.

9.1 견적/세션 생성 — negodata (인수인계 대상)

✅ 2026-07-04 적용 완료(인수인계.md §1 참조). 적용된 이식판은 아래 2번의 Redis GET/SET 없이 current_values 뷰 직조회 → 정적 테이블 폴백으로 동작한다(단순화 결정).

구(舊) negodata _build_quotation은 세션 생성 시 anchoring_price를 구 방식으로 채웠다 (신규: int(tp * (1 - quotation_settings.anchoring_value)) float 계산 / 재생성: 직전 라운드 값 상속). 새 앵커링 모듈 전달 후 아래로 교체된다:

세션(상품 × 공급사) 생성 시마다:
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
   price_range_index = calc_price_range_index(target_price)   # 자릿수 사다리 — service 모듈 함수 이식
2. rate 조회 (모듈의 reader 이식):
   supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
   그 외(미지정 등)       → 정적 테이블 시작값 (유일 폴백 — §12)
3. anchor_price = target_price × (1000 − rate) // 1000   ← 정수 연산 MUST (기존 float 식 폐기)
4. 세션 INSERT 에 anchoring_price = anchor_price, anchoring_value = rate 포함 (박제)
  • 재생성 상속 폐지 (MUST): 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
  • quotation_settings.anchoring_value 는 앵커가 계산에 더 이상 사용하지 않는다(컬럼·화면 표기는 유지 가능).
  • 전환기 동작: negodata 적용 전까지는 구 방식 값이 계속 박제된다 — 판정(§4.3)은 박제된 anchor 기준이므로 표본·조정은 그동안에도 유효하게 쌓이고, negodata 적용 시점부터 조정된 rate 가 실제 기준가에 반영되기 시작한다(자연 부트스트랩, 별도 마이그레이션 불필요).

9.2 협상 채팅 — backend (직접 구현, anchoring 모듈 무의존)

backend/services/chat_service.py::_resolve_anchor_price — quotation_settings.anchoring_value 읽기 삭제. _agent_context가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다.

1. 박제값 사용 (MUST): sessions.anchoring_price 를 그대로 사용.
   → negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
   → 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
   박제하지 않는다 → 이 세션은 anchor 박제가 없어 배치 판정에서 자동 EXCLUDED (학습 무오염).
   agent 에는 양수 anchor 가 보장되어 기존 검증(ValueError) 안전.
3. agent 컨텍스트로 anchor_price 전달 (기존 AgentChatContext.anchor_price 그대로)
  • 퇴화 케이스: target_price 가 0/NULL 인 세션은 anchor 0 을 반환한다(기존 동작 보존) — 정상 데이터에서는 발생하지 않는다.

가격 흔적 기록 (MUST):

  • agent 는 변경하지 않는다. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
  • backend send()가 가격 입력 턴(price is not None)의 봇 메시지를 저장하는 트랜잭션에 UPDATE sessions SET last_offer_price = :price WHERE session_id = :id를 함께 넣는다 — 메시지 저장과 원자적, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
  • 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).

10. 참조 구현

# src/anchoring/service.py  (순수 함수만 — DB/Redis 접근 없음)
from bisect import bisect_right

from anchoring.constants import (
    ANCHORING_VALUE_MIN, ANCHORING_VALUE_MAX, ADJUSTMENT_STEP,
    SAMPLE_THRESHOLD, UPPER_BOUNDS, PRICE_RANGE_INDEX_MAX,
    AnchoringSampleType,
)
from anchoring.base_table import get_base_anchoring_value   # 정적 테이블 조회 (§2)


def calc_price_range_index(target_price: int) -> int:
    """목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
    좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
    정적 테이블 idx = 반환값 + 1"""
    return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)


def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
    """앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
    return target_price * (1000 - anchoring_value) // 1000


def judge_sample_type(
    is_done: bool,                    # sessions.status == DONE(3)
    bid_price: int | None,            # 확정 투찰가(DONE 시)
    last_offer_price: int | None,   # 마지막 제시가 — NULL 이면 가격 흔적 없음
    anchor_price: int | None,         # sessions.anchoring_price (박제 앵커)
) -> int:
    """배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
    if anchor_price is None or last_offer_price is None:
        return AnchoringSampleType.EXCLUDED.value
    if is_done and bid_price is not None and bid_price <= anchor_price:
        return AnchoringSampleType.BID_SUCCESS.value
    return AnchoringSampleType.BID_FAIL.value


def evaluate_samples(
    value_before: int,
    sample_types: list[int],   # 미처리 유효 표본 전량의 판정 코드
    supplier_type: int,        # SMALLINT 코드 1/2/3
) -> tuple[int, bool] | None:
    """누적 전량 평가. §4.4
    반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월)
    clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와
    구분되는 실제 포화 신호(운영 지표용).
    호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
    """
    n = len(sample_types)
    if n < SAMPLE_THRESHOLD:
        return None

    success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
    delta = ADJUSTMENT_STEP[supplier_type]

    # r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
    if success * 10 >= n * 6:
        adjusted = value_before + delta
    elif success * 10 < n * 3:          # r < 0.30
        adjusted = value_before - delta
    else:                                # 0.30 ≤ r < 0.60
        adjusted = value_before

    value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted))
    return value_after, value_after != adjusted


def get_current_value(latest_adjusted_rate: int | None, price_range_index: int) -> int:
    """현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
    if latest_adjusted_rate is not None:
        return latest_adjusted_rate
    return get_base_anchoring_value(price_range_index)   # int(round(anchoring_value * 1000))
-- 배치의 미처리 세션 스캔 (§8 절차 1)
-- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어
-- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지.
SELECT s.session_id, s.status, s.bid_price, s.target_price,
       s.anchoring_price, s.last_offer_price,
       q.supplier_type, i.company_id
FROM negotiation.sessions s
LEFT JOIN quotation.quotations q ON q.qt_id  = s.quotation_id AND q.deleted = false
LEFT JOIN partner.items        i ON i.item_id = s.item_id     AND i.deleted = false
WHERE s.used_by_adjustment_id IS NULL
  AND s.deleted = false
  AND s.qt_type = 1
  AND s.status IN (3, 4, 5)
-- 현재 값 조회 (캐시 미스 시)
SELECT anchoring_value_after
FROM anchoring.adjustments
WHERE company_id = :c AND supplier_type = :p AND price_range_index = :b
ORDER BY id DESC
LIMIT 1

11. 검증 벡터 (Golden Tests)

아래 케이스가 전부 성립해야 한다. 위치: 골든 벡터·배치 통합 = schedules/anchoring/tests/, 가격 흔적 기록·NULL 폴백 = backend/tests/. 단 Redis 의존 케이스(re-SET 회복)와 다중 프로세스 동시 실행·타이밍 케이스는 자동 스위트(무Redis·단일 프로세스)가 아닌 수동/후속 검증 대상이다 — 자동화된 것은 pytest 로 고정돼 있다.

11.1 앵커링가 계산 (내림 검증)

target_price rate(‰) 계산 anchor_price
30,000 200 30,000 × 800 // 1000 24,000
26,706 10 26,706 × 990 // 1000 = 26,438.94 → 내림 26,438
29,999 15 29,999 × 985 // 1000 = 29,549.015 → 내림 29,549
0 10 0 0

11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)

target_price price_range_index 정적 테이블 idx 칸
0 0 1 [0, 1,000) 통일 칸
999 0 1 [0, 1,000)
1,000 1 (경계는 상위 구간) 2 1천 원대
9,999 9 10 9천 원대
10,000 10 11 1만 원대
30,000 12 13 3만 원대
150,000 19 20 10만 원대
99,999,999 45 (마지막 구간) 46 9천만 원대
100,000,000 45 46 마지막 칸
150,000,000 45 (1억 초과 → 마지막 인덱스 클램프) 46 마지막 칸

정적 테이블 검증: 46행 · idx 1..46 연속 · upper_bound == UPPER_BOUNDS[i](사다리 대조) · 마지막 100,000,000.

11.3 누적 전량 평가 (유통 코드1, δ=20, value_before=10)

pending 구성 n r 판정 anchoring_value_after
성공 8 / 실패 5 13 ≈ 0.615 ≥ 0.60 → +20 30
성공 7 / 실패 6 13 ≈ 0.538 유지 10
성공 3 / 실패 10 13 ≈ 0.231 < 0.30 → −20 10 (하한 clamp)
성공 6 / 실패 4 10 0.60 정확히 경계 포함 → +20 30
성공 3 / 실패 7 10 0.30 정확히 유지 10
성공 9 / 실패 0 9 — 평가 안 함 (이월) None

δ 스왑 가드 (MUST): evaluate_samples(10, [성공10/10], supplier_type=2) == (20, False) (제조 +10), supplier_type=3 → (25, False) (총판 +15).

clamp·격리 케이스:

시나리오 기대
value_before 200, r = 0.9 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨
A사 칸 평가 B사의 같은 (p, b) 칸 값에 영향 없음
조정 이력 없는 칸 정적 테이블 시작값(10) 반환
EXCLUDED 15건 + 유효 5건 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리

11.4 파생 판정

status bid_price last_offer_price anchor_price 기대
DONE 24,000 24,000 24,000 BID_SUCCESS (같아도 성공)
DONE 24,001 24,001 24,000 BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등)
REJECTED NULL 25,000 24,000 BID_FAIL (가격 쓰고 결렬)
NOT_PARTICIPATED (일괄마감) NULL 25,000 24,000 BID_FAIL (가격 쓰고 중간 이탈)
임의 종료 상태 NULL NULL 24,000 EXCLUDED (가격 흔적 없음)
DONE 24,000 24,000 NULL EXCLUDED (앵커 박제 없음)

11.5 배치 멱등성·이월·소비 (DB 통합 — 세션 시드 기반)

시나리오 기대
유효 13건 시드 → 배치 조정 1행(n=13, used_session_ids 13개 박제, 10→30) + 13건 모두 used_by_adjustment_id=조정 id
직후 배치 재실행 조정 0건 (전 칸 pending < 10 — 마킹 멱등)
2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 4주 차 배치에서 13건 전량 1회 평가
배치 1회 누락 → 다음 배치 4주치 pending으로 1스텝 평가, 별도 보정 불필요
supplier_type NULL 세션 집계 제외 + 마킹 0, 이후 배치에서 재스캔 안 됨
세션 종료가 배치 스캔 직후 커밋 마킹 안 됐으므로 다음 배치에서 정상 소비 (영구 누락 없음)
마킹 rowcount ≠ n (경합 시뮬레이션: pending 일부를 미리 마킹) 조정 INSERT 포함 전체 롤백 — 조정 0건, 이중 조정 없음
배치 2개 프로세스 동시 실행(오설정 시뮬레이션) 한쪽만 조정 성공, 다른 쪽은 rowcount 불일치 롤백 → 칸당 조정 정확히 1건
Redis 에 옛 rate 를 심고 주간 잡 실행(격주 게이트 OFF 주) 절차 0.5 re-SET 으로 최신 rate 로 회복
가격 제시율 0% 상태에서 배치 실행 요약 로그에 WARN 출력 (backend 기록 배선 유실 감지)
dry-run 실행 (유효 10건 + 제외 1건 시드) status=dry_run·조정예정 로그만 — 조정 0행·마킹 없음(제외 포함). 직후 실제 실행 시 그대로 반영(상태 미소비 증명)
rate=10‰ 박제인데 anchor 가 정수식과 다른 세션 "박제 정합 불일치 1건" WARN (판정은 박제 anchor 기준 그대로)

11.6 읽기 경로·가격 흔적 (E2E 스모크)

시나리오 기대
재협상 채팅 → 가격 입력 턴 last_offer_price 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제)
가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) last_offer_price 보존 → 배치에서 BID_FAIL 표본
같은 세션에서 배치가 값 변경 후 다음 턴 앵커 불변 (박제값 사용)
박제 없는 세션(NULL 폴백) anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작
Redis 정지 상태에서 견적 생성(negodata reader) DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백)

12. 금지·봉인 사항

구현자가 임의로 추가·가정하면 안 되는 항목:

  • 극희소 칸 fallback (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
  • 정적 기본 테이블 변경 — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
  • 조정 이력의 UPDATE/DELETE, 소급 무효화·보정 — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
  • sessions 판정 입력 컬럼(anchoring_price, anchoring_value)의 사후 수정, last_offer_price 의 종료 후 수정 — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 used_by_adjustment_id 단 하나.
  • 파라미터 동적 조정 (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
  • 성공률 외 신호 반영 (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
  • 회사 간 값·표본 공유 또는 전사 통합 평가 — 칸은 회사별 완전 독립.
  • float 산술 — 앵커링가·rate 계산에 부동소수점 사용 금지 (round(target*0.99) 패턴 금지).
  • 앵커가 노출 — 앵커가를 협력사 화면에 표시하는 변경은 판정 의미론(§4.3의 무편향 전제)까지 바꾸는 정책 재확정 사안.

13. 선행·연계 작업

담당 구분: [우리] = backend/schedules 직접 구현(완료), [인수인계] = 모듈·명세를 전달 → 담당 개발자가 적용.

# 항목 담당 상태
1 DDL — anchoring.adjustments + sessions 3컬럼 ALTER [우리 — 모듈] postgres-init/05-anchoring-schema.sql, psql 적용 시점 협의 구현 완료 (§6)
2 세션 생성 시 앵커 산출을 새 시스템으로 교체 — _build_quotation 앵커 계산 교체 + 재생성 상속 폐지 [인수인계 — negodata] §9.1. reader 는 모듈(async)에서 그대로 이식
3 agent 변경 없음 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님
4 재협상 식별 — sessions.qt_type = 1 로 판별 (확인됨)
5 company_id 식별 — partner.items.company_id (세션→item 조인, 기존 _agent_context 해석 방식과 동일)
6 quotations.supplier_type 기록 [인수인계 — negodata] 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0)
7 정적 테이블 로드 검증 [우리 — 모듈] 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료
8 Redis 인프라 [우리 — 모듈] 루트 docker-compose 에 redis 동봉(모듈 전용 캐시). negodata 는 무Redis(뷰 직조회)·backend 도 무의존
9 스케줄러·배치 [우리 — 모듈] 자립 컨테이너(APScheduler, --once 수동 실행 지원). 구현 완료
10 backend 채팅 수정 [우리 — backend] _resolve_anchor_price 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 last_offer_price 갱신, sessions 모델 3컬럼, quotation_settings.anchoring_value 읽기 제거(컬럼은 유지). 구현 완료