ядро Коди: чемпион/претендент без утечки + база как порог допуска, снятие чемпиона

Найдено на проде: чемпион, обученный на всём (включая холдаут), сравнивался
на этом же холдауте — видел ответы и выигрывал всегда (адресация: 0.745
против 0.81 у претендента), а task_fit на холдауте при выборе проигрывал
константе (0.849 против 0.707), но стал чемпионом, потому что чемпиона не было.
- чемпион помнит trained_upto; честное сравнение — только на свежих
  (≥ 10, не виденных чемпионом); мало свежих — чемпион остаётся;
- допуск: претендент обязан бить базовую линию (константа = доля класса
  в обучении); чемпион, на свежих хуже базы, снимается (CoreModelRetired),
  ученик молчит; cli.py core retire --head; откат возвращает снятого;
- прод: task_fit снят вручную (при выборе хуже базы).
Тест: test_kodi_core_tasks 20 PASS (свежие/честно/снятие/откат, шум не
промоутится); полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-17 09:36:44 +00:00
parent ebcfe3b9be
commit f32c127c6b
3 changed files with 137 additions and 24 deletions

5
cli.py
View file

@ -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:

View file

@ -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"]
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))
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:])
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 = []

View file

@ -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())