File size: 8,563 Bytes
fe31cf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4819352
 
fe31cf6
 
 
 
 
 
 
 
4819352
fe31cf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4819352
fe31cf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4819352
fe31cf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4819352
fe31cf6
 
 
 
 
 
 
 
 
4819352
fe31cf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4819352
fe31cf6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
from __future__ import annotations
"""
λ¦¬νŒ©ν† λ§ νšŒκ·€ μŠ€λƒ…μƒ· ν•˜λ‹ˆμŠ€ (Step A).

config-driven 수렴 λ¦¬νŒ©ν† λ§(Phase 3~4)의 μ•ˆμ „λ§.
"λ¦¬νŒ©ν† λ§ μ „ == ν›„"λ₯Ό κΈ°κ³„μ μœΌλ‘œ 증λͺ…ν•œλ‹€ β€” 3μ‹œλ‚˜λ¦¬μ˜€ 전체 intent score + feature dict 1:1 비ꡐ.

두 μŠ€λƒ…μƒ· νŒ¨λ°€λ¦¬:
  1. scores   : seed_dataset의 (쀑볡 제거된) survey_answers β†’ infer_batch β†’ {intent_id: score}.
                build_batch_features(Index/Score) + rule_predict + model_predict 전체 경둜 컀버.
  2. features : κ³ μ • ν•©μ„± 이벀트 μ‹œν€€μŠ€ β†’ engine.pattern_features/event_features dict.
                pattern/event μΆ”μΆœκΈ°(μ—”ν‹°ν‹°β†’κ·Έλ£ΉΒ·ν”Œλž˜κ·Έ λ§΅) 컀버. (νƒ€μž„μŠ€νƒ¬ν”„λ₯˜ ν•„λ“œλŠ” 비ꡐ μ œμ™Έ)

μ‚¬μš©λ²•:
  python scripts/regression_snapshot.py --save     # ν˜„μž¬ λ™μž‘μ„ baseline으둜 μ €μž₯
  python scripts/regression_snapshot.py --check     # ν˜„μž¬ λ™μž‘ vs baseline (뢈일치 μ‹œ exit 1)

baseline: .documents/_snapshots/{scenario_id}.json  (gitignore 경둜)
"""
import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from core.engines import available_scenarios, get_engine, config  # noqa: E402
from core.extractor import get_extractor                           # noqa: E402
from core.inference import infer_batch                             # noqa: E402

_SNAPSHOT_DIR = Path(__file__).parent.parent / ".documents" / "_snapshots"

# infer 결과의 비결정적/무관 ν•„λ“œ (μŠ€λƒ…μƒ· 비ꡐ μ œμ™Έ)
_VOLATILE_KEYS = {"last_event_at"}

_ROUND = 6


# ── μ •κ·œν™” (λΆ€λ™μ†Œμˆ˜Β·νƒ€μž… μ•ˆμ •ν™”) ───────────────────────────────
def _norm(v: object) -> object:
    """비ꡐ μ•ˆμ •ν™”: floatλŠ” _ROUND 자리 반올림, κ·Έ μ™Έ(bool 포함)λŠ” κ·ΈλŒ€λ‘œ."""
    if isinstance(v, float):
        return round(v, _ROUND)
    if isinstance(v, bool):
        return v
    return v


def _norm_dict(d: dict) -> dict:
    """dictλ₯Ό ν‚€ μ •λ ¬Β·κ°’ μ •κ·œν™”ν•˜κ³  νœ˜λ°œμ„± ν‚€(_VOLATILE_KEYS)λŠ” μ œμ™Έ."""
    return {k: _norm(v) for k, v in sorted(d.items()) if k not in _VOLATILE_KEYS}


# ── 1. scores μŠ€λƒ…μƒ· ────────────────────────────────────────────
def _unique_answers(scenario_id: str) -> list[dict]:
    """seed_dataset의 survey_answersλ₯Ό 쀑볡 μ œκ±°ν•˜μ—¬ 결정적 μˆœμ„œλ‘œ λ°˜ν™˜."""
    path = Path(__file__).parent.parent / "scenarios" / scenario_id / "seed_dataset.json"
    data = json.loads(path.read_text(encoding="utf-8"))
    seen: dict[tuple, dict] = {}
    for s in data["samples"]:
        ans = s["survey_answers"]
        key = tuple(sorted(ans.items()))
        seen.setdefault(key, ans)
    # λ‹΅λ³€ ν‚€ μ •λ ¬ λ¬Έμžμ—΄λ‘œ 결정적 μ •λ ¬
    return [seen[k] for k in sorted(seen.keys())]


def _scores_snapshot(scenario_id: str) -> list[dict]:
    """쀑볡 제거된 survey_answersλ§ˆλ‹€ infer_batch β†’ {answers, intent별 final_score}."""
    out = []
    for ans in _unique_answers(scenario_id):
        _, scores = infer_batch(ans, scenario_id)
        out.append({
            "answers": {k: ans[k] for k in sorted(ans)},
            "scores": {s.intent_id: round(s.final_score, _ROUND) for s in scores},
        })
    return out


# ── 2. features μŠ€λƒ…μƒ· (pattern/event μΆ”μΆœκΈ° 컀버) ──────────────
def _synthetic_sequence(scenario_id: str) -> list[tuple[str, str]]:
    """behavior_signals의 entity 전체λ₯Ό (click, entity) 이벀트둜 β€” λ§€ν•‘ ν…Œμ΄λΈ” μ „μˆ˜ 컀버.
    λ§ˆμ§€λ§‰ entityλ₯Ό ν•œ 번 더 λ°˜λ³΅ν•΄ repeated/dominant 집계도 자극."""
    entities = sorted(config.get_behavior_signals(scenario_id).keys())
    seq = [("click", e) for e in entities]
    if entities:
        seq.append(("click", entities[0]))  # 반볡 1건
    return seq


def _features_snapshot(scenario_id: str) -> dict:
    """ν•©μ„± 이벀트 μ‹œν€€μŠ€λ₯Ό μ£Όμž…ν•΄ empty/pattern/event Feature dictλ₯Ό μŠ€λƒ…μƒ·(νƒ€μž„μŠ€νƒ¬ν”„ μ œμ™Έ)."""
    engine = get_engine(scenario_id)
    ext = get_extractor()
    session = f"__snapshot__{scenario_id}"
    ext.reset(session)
    for event_type, entity in _synthetic_sequence(scenario_id):
        ext.add_event(session, event_type, entity)  # occurred_at=now β†’ window λ‚΄

    snap = {
        "empty_pattern": _norm_dict(engine.empty_pattern_features()),
        "empty_event":   _norm_dict(engine.empty_event_features()),
        "pattern":       _norm_dict(engine.pattern_features(session)),
        "event":         _norm_dict(engine.event_features(session)),
    }
    ext.reset(session)
    return snap


# ── μŠ€λƒ…μƒ· λΉŒλ“œ ─────────────────────────────────────────────────
def _build(scenario_id: str) -> dict:
    """ν•œ μ‹œλ‚˜λ¦¬μ˜€μ˜ 전체 μŠ€λƒ…μƒ·(scores + features) 생성."""
    return {
        "scenario_id": scenario_id,
        "scores": _scores_snapshot(scenario_id),
        "features": _features_snapshot(scenario_id),
    }


# ── diff ────────────────────────────────────────────────────────
def _diff(old: dict, new: dict, scenario_id: str) -> list[str]:
    """baseline(old) vs ν˜„μž¬(new) μŠ€λƒ…μƒ· 비ꡐ β†’ 뢈일치 λ©”μ‹œμ§€ 리슀트(빈 리슀트면 무손상)."""
    errs: list[str] = []

    # features
    for fam in ("empty_pattern", "empty_event", "pattern", "event"):
        o, n = old["features"].get(fam, {}), new["features"].get(fam, {})
        for k in sorted(set(o) | set(n)):
            if o.get(k) != n.get(k):
                errs.append(f"[{scenario_id}] features.{fam}.{k}: {o.get(k)} β†’ {n.get(k)}")

    # scores (answers μ •λ ¬ 동일 κ°€μ •, 길이/μˆœμ„œ 검증)
    o_cases, n_cases = old["scores"], new["scores"]
    if len(o_cases) != len(n_cases):
        errs.append(f"[{scenario_id}] scores μΌ€μ΄μŠ€ 수: {len(o_cases)} β†’ {len(n_cases)}")
    for i, (oc, nc) in enumerate(zip(o_cases, n_cases)):
        if oc["answers"] != nc["answers"]:
            errs.append(f"[{scenario_id}] scores[{i}] answers 뢈일치")
            continue
        os_, ns_ = oc["scores"], nc["scores"]
        for iid in sorted(set(os_) | set(ns_)):
            if os_.get(iid) != ns_.get(iid):
                errs.append(f"[{scenario_id}] scores[{i}].{iid}: {os_.get(iid)} β†’ {ns_.get(iid)}")
    return errs


# ── main ────────────────────────────────────────────────────────
def main() -> None:
    """--save: baseline μ €μž₯ / --check: baseline λŒ€λΉ„ 검증(뢈일치 μ‹œ exit 1)."""
    ap = argparse.ArgumentParser()
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--save", action="store_true", help="baseline μ €μž₯")
    g.add_argument("--check", action="store_true", help="baseline λŒ€λΉ„ 검증")
    ap.add_argument("--scenarios", nargs="*", default=None, help="λŒ€μƒ μ‹œλ‚˜λ¦¬μ˜€(κΈ°λ³Έ: 전체)")
    args = ap.parse_args()

    _SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
    scenarios = args.scenarios or available_scenarios()

    if args.save:
        for sid in scenarios:
            snap = _build(sid)
            path = _SNAPSHOT_DIR / f"{sid}.json"
            path.write_text(json.dumps(snap, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
            print(f"saved {path}  (scores={len(snap['scores'])} cases)")
        return

    # --check
    all_errs: list[str] = []
    for sid in scenarios:
        path = _SNAPSHOT_DIR / f"{sid}.json"
        if not path.exists():
            print(f"❌ baseline μ—†μŒ: {path} (λ¨Όμ € --save)")
            sys.exit(2)
        old = json.loads(path.read_text(encoding="utf-8"))
        new = _build(sid)
        errs = _diff(old, new, sid)
        if errs:
            all_errs.extend(errs)
        print(f"{'❌' if errs else 'βœ…'} {sid}: {len(errs)} diff (scores={len(new['scores'])} cases)")

    if all_errs:
        print("\n── 뢈일치 상세 (μ΅œλŒ€ 50건) ──")
        for e in all_errs[:50]:
            print("  " + e)
        print(f"\n총 {len(all_errs)}건 뢈일치 β†’ νšŒκ·€ λ°œμƒ")
        sys.exit(1)
    print("\nβœ… 무손상 β€” μ „ μ‹œλ‚˜λ¦¬μ˜€ scoreΒ·feature 1:1 일치")


if __name__ == "__main__":
    main()