diff --git a/cli.py b/cli.py index bf74cb5..4a8d506 100644 --- a/cli.py +++ b/cli.py @@ -204,7 +204,7 @@ def main(): p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)") p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback", - "reflect", "report"]) + "reflect", "report", "retire"]) p_core.add_argument("--head", default=None, help="голова: addressing | task_fit (train/rollback; без — все для train)") p_dec = sub.add_parser("decide", help="decision-модель nimble (Ollama /v1/systemone): статус, вопрос, статистика") @@ -671,6 +671,9 @@ def main(): print(f"обратная связь: {kodi_core.harvest_feedback()}") elif args.action == "reflect": print(f"задачи рефлексии: {kodi_core.reflect()}") + elif args.action == "retire": + print(json.dumps(kodi_core.retire(args.head or kodi_core.HEAD, "снят вручную"), + ensure_ascii=False)) elif args.action == "report": print(kodi_core.weekly_report(force=True) or "SKIP: уже отправлен на этой неделе") else: diff --git a/harness/kodi_core.py b/harness/kodi_core.py index 8dac9ea..91c86b4 100644 --- a/harness/kodi_core.py +++ b/harness/kodi_core.py @@ -66,6 +66,7 @@ NOV_NOVEL = 0.55 # покрытие 25%, совпадение с учителем 95%; 0.35 → 47% / 93%; 0.3 → 60% / 90% CONF = 0.4 MIN_TRAIN = 40 +FRESH_MIN = 10 # свежих (не виденных чемпионом) примеров для честного сравнения WEIGHTS = {"teacher": 1.0, "teacher_backfill": 1.0, "feedback": 3.0, "human": 5.0} NEG_RE = re.compile(r"не\s+(?:тебе|к\s+тебе|тебя\s+спрашива)|молчи|не\s+встревай|" @@ -388,7 +389,7 @@ def _forget(head: str): _STATE.pop(head, None) -def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES): +def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES, trained_upto=""): import io name = f"model-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}.npz" buf = io.BytesIO() @@ -403,9 +404,11 @@ def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES): sha = hashlib.sha256(blob).hexdigest() prev = _champion_meta(head) meta = {"file": name, "sha256": sha, "metrics": metrics_, "n": n_total, + "trained_upto": trained_upto, "kind": "logreg", "features": {"emb": EMB_MODEL, "flags": list(flag_names)}, "ts": datetime.now(timezone.utc).isoformat(), - "history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts") if k in prev}] + "history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts", "trained_upto") + if k in prev}] + prev.get("history", []))[:10] if prev.get("file") else []} tmp = _model_path(".champion.tmp", head) with open(tmp, "w") as f: @@ -418,14 +421,32 @@ def _heads(): """Реестр голов: dataset() → [(item, y, w, src)], featurize(items) → (X, em).""" return { HEAD: {"dataset": lambda: [((r[0], r[1]), r[2], r[3], r[4]) for r in dataset()], - "featurize": featurize, "nf": NF, "flags": FLAG_NAMES}, + "featurize": featurize, "nf": NF, "flags": FLAG_NAMES, + "ts": lambda it: it[0].get("ts")}, TASK_HEAD: {"dataset": task_dataset, "featurize": task_featurize, - "nf": len(TASK_FLAGS), "flags": TASK_FLAGS}, + "nf": len(TASK_FLAGS), "flags": TASK_FLAGS, "ts": lambda t: t.get("ts")}, } +def _logloss(p, y, w) -> float: + p = np.clip(p, 1e-6, 1 - 1e-6) + return float(-(w * (y * np.log(p) + (1 - y) * np.log(1 - p))).sum() / w.sum()) + + def train(log=print, head: str = HEAD) -> dict: - """Претендент против чемпиона на временном холдауте; лучше — промоут.""" + """Чемпион/претендент без утечки. + + Чемпион обучен на всём, что видел до trained_upto, — сравнивать его на + этих же данных нечестно (он видел ответы и выигрывал всегда: прод + 2026-10-17, 0.745 против 0.81). Поэтому: + - свежих (после trained_upto) ≥ FRESH_MIN → холдаут = свежие; честно + сравниваются претендент (обучен на остальном), чемпион и база; + - иначе холдаут = последние 20%; чемпиона не сравнить — он остаётся, + претендент без чемпиона проходит только при победе над базой; + - база — константа = доля класса в обучении; ученик, не бьющий базу, — + шум с уверенным лицом: не промоутится, а чемпион на свежих хуже базы + снимается (ученик молчит, решает учитель/правило). + """ spec = _heads()[head] rows = spec["dataset"]() if len(rows) < MIN_TRAIN: @@ -434,47 +455,96 @@ def train(log=print, head: str = HEAD) -> dict: X, em = spec["featurize"]([r[0] for r in rows]) y = np.asarray([r[1] for r in rows], np.float64) w = np.asarray([r[2] for r in rows], np.float64) + ts = [spec["ts"](r[0]) or "" for r in rows] nf = spec["nf"] - cut = max(int(len(rows) * 0.8), len(rows) - max(10, len(rows) // 5)) - ch = train_logreg(X[:cut], y[:cut], w[:cut], nf=nf) - ch["mem"] = em[:cut] - m_ch = metrics(ch, X[cut:], y[cut:], w[cut:]) - nov_h = novelty(ch, em[cut:]) - nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)]) - p_h = predict(ch, X[cut:]) + meta0 = _champion_meta(head) + champ = load_champion(head) + # чемпионы до появления trained_upto: всё до момента обучения он видел + upto = meta0.get("trained_upto") or meta0.get("ts") or "" + fresh = np.asarray([t > upto for t in ts]) if (champ is not None and upto) else None + if fresh is not None and fresh.sum() >= FRESH_MIN: + hold, train_m, fair = fresh, ~fresh, True + else: + cut = max(int(len(rows) * 0.8), len(rows) - max(10, len(rows) // 5)) + hold = np.arange(len(rows)) >= cut + train_m, fair = ~hold, False + ch = train_logreg(X[train_m], y[train_m], w[train_m], nf=nf) + ch["mem"] = em[train_m] + yh, wh = y[hold], w[hold] + m_ch = metrics(ch, X[hold], yh, wh) + p_h = predict(ch, X[hold]) sure = np.abs(p_h - .5) >= CONF m_ch["confident_share"] = round(float(sure.mean()), 3) - m_ch["confident_acc"] = (round(float(((p_h[sure] >= .5) == (y[cut:][sure] >= .5)).mean()), 3) + m_ch["confident_acc"] = (round(float(((p_h[sure] >= .5) == (yh[sure] >= .5)).mean()), 3) if sure.any() else None) - m_ch["base_rate"] = round(float((y[cut:] >= .5).mean()), 3) + m_ch["base_rate"] = round(float((yh >= .5).mean()), 3) + pb = float(np.clip((w[train_m] * y[train_m]).sum() / w[train_m].sum(), 1e-3, 1 - 1e-3)) + m_ch["baseline_logloss"] = round(_logloss(np.full(len(yh), pb), yh, wh), 4) + nov_h = novelty(ch, em[hold]) + nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)]) m_ch["nov_q"] = [round(float(v), 4) for v in nov_q] - champ = load_champion(head) + m_ch["fair_vs_champion"] = fair m_old = None - if champ is not None: + if champ is not None and fair: try: - m_old = metrics(champ, X[cut:], y[cut:], w[cut:]) + m_old = metrics(champ, X[hold], yh, wh) except Exception: - m_old = None # другой формат признаков — сравнивать нельзя, меняем - better = m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4 + m_old = None # другой формат признаков — сравнить нельзя + beats_base = m_ch["logloss"] < m_ch["baseline_logloss"] - 1e-4 + if champ is None: + better = beats_base + elif fair: + better = beats_base and (m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4) + else: + better = False # чемпиона не сравнить честно — ждём свежих данных + retired = False + if (not better and fair and m_old is not None + and m_old["logloss"] >= m_ch["baseline_logloss"] - 1e-4): + retire(head, "на свежих данных не лучше базовой линии", m_old, m_ch["baseline_logloss"]) + retired = True srcs = {} for r in rows: srcs[r[3]] = srcs.get(r[3], 0) + 1 rep = {"head": head, "n": len(rows), "holdout": m_ch, "champion_holdout": m_old, - "sources": srcs, "promoted": bool(better)} + "sources": srcs, "promoted": bool(better), "beats_baseline": bool(beats_base), + "retired": retired} if better: full = train_logreg(X, y, w, nf=nf) full["mem"] = em full["nov_q"] = nov_q - meta = _save(full, m_ch, len(rows), head, spec["flags"]) + meta = _save(full, m_ch, len(rows), head, spec["flags"], trained_upto=max(ts)) _forget(head) es.append_event("core", f"promote:{head}:{meta['file']}", "CoreModelPromoted", { "head": head, "file": meta["file"], "sha256": meta["sha256"], "holdout": m_ch, "champion_holdout": m_old, "n": len(rows), "sources": srcs}) - log(f"[core] {head}: n={len(rows)} претендент {m_ch} чемпион {m_old} → " - f"{'ПРОМОУТ' if better else 'чемпион остаётся'}") + verdict = ("ПРОМОУТ" if better else "снят: на свежих хуже базы" if retired + else "не лучше базовой линии — без промоута" if not beats_base + else "чемпион остаётся (мало свежих для честного сравнения)" if not fair + else "чемпион остаётся") + log(f"[core] {head}: n={len(rows)} претендент {m_ch} чемпион {m_old} → {verdict}") return rep +def retire(head: str, reason: str, holdout=None, baseline=None) -> dict: + """Снять чемпиона: ученик молчит, решает учитель/правило. Откат — rollback.""" + meta0 = _champion_meta(head) + if not meta0.get("file"): + return {"ok": False, "reason": "чемпиона нет"} + tmp = _model_path(".champion.tmp", head) + with open(tmp, "w") as f: + json.dump({"file": None, "retired": meta0.get("file"), "reason": reason, + "ts": datetime.now(timezone.utc).isoformat(), + "history": ([{k: meta0[k] for k in ("file", "sha256", "metrics", "ts", + "trained_upto") if k in meta0}] + + meta0.get("history", []))[:10]}, f, ensure_ascii=False, indent=1) + os.replace(tmp, _model_path("champion.json", head)) + _forget(head) + es.append_event("core", f"retire:{head}:{int(time.time())}", "CoreModelRetired", { + "head": head, "file": meta0.get("file"), "reason": reason, + "holdout": holdout, "baseline_logloss": baseline}) + return {"ok": True, "retired": meta0.get("file")} + + def train_all(log=print) -> list: """Ночная тренировка всех голов (дневной тик).""" out = [] diff --git a/tests/test_kodi_core_tasks.py b/tests/test_kodi_core_tasks.py index 9eebab5..443c83d 100644 --- a/tests/test_kodi_core_tasks.py +++ b/tests/test_kodi_core_tasks.py @@ -122,6 +122,46 @@ check("после мин-попытки прогноз не повторяетс sum(1 for e in es.get_events(0, 10**6) if e["event_type"] == "CorePrediction") == n_before) os.environ.pop("SIMBIOT_CORE_TASK_FIT") +# ── честное сравнение: без свежих данных чемпион остаётся; на свежих, где он +# систематически ошибается (мир поменялся), — снимается ── +kodi_core._forget("task_fit") +rep_same = kodi_core.train(log=lambda *_: None, head="task_fit") +check("без свежих — сравнение нечестное, чемпион остаётся", + not rep_same["promoted"] and not rep_same["holdout"]["fair_vs_champion"] + and kodi_core._champion_meta("task_fit").get("file"), rep_same) +check("чемпион помнит, до какого момента видел данные", + kodi_core._champion_meta("task_fit").get("trained_upto")) +for i in range(14): # инверсия: «тяжёлые» теперь влезают, «лёгкие» — нет + task(f"тяжёлая: теперь простая {i}", claims=["min"], done=True) + task(f"лёгкая: теперь сложная {i}", claims=["min", "min"], done=True, promote="guard") +kodi_core._forget("task_fit") +rep_flip = kodi_core.train(log=lambda *_: None, head="task_fit") +check("свежие → честное сравнение", rep_flip["holdout"]["fair_vs_champion"], rep_flip["holdout"]) +check("чемпион на свежих хуже базы — снят", + rep_flip["retired"] and kodi_core._champion_meta("task_fit").get("file") is None + and kodi_core.load_champion("task_fit") is None, rep_flip) +check("снятие — подписанное событие", + any(e["event_type"] == "CoreModelRetired" for e in es.get_events(0, 10**6))) +check("снятого можно вернуть откатом", kodi_core.rollback("task_fit")["ok"] + and kodi_core._champion_meta("task_fit").get("file")) + +# ── база: ученик на шуме не становится чемпионом, плохой чемпион снимается ── +import random +random.seed(7) +os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core-noise") +kodi_core._forget("task_fit") +for i in range(60): # метка случайна относительно текста — учить нечего + if random.random() < .5: + task(f"лёгкая: шум {i}", claims=["min"], done=True) + else: + task(f"лёгкая: шум {i}", claims=["min", "min"], done=True, promote="guard") +rep_noise = kodi_core.train(log=lambda *_: None, head="task_fit") +check("шум: претендент не лучше базы — не промоутится", + rep_noise["holdout"]["logloss"] >= rep_noise["holdout"]["baseline_logloss"] - 1e-4 + and not rep_noise["promoted"] or rep_noise["beats_baseline"], rep_noise) +os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core") +kodi_core._forget("task_fit") + st = kodi_core.status() check("статус: обе головы", set(st) == {"addressing", "task_fit"} and st["task_fit"]["champion"], st.keys())