diff --git a/docs/experiments/19-editor-contract-q4b.md b/docs/experiments/19-editor-contract-q4b.md new file mode 100644 index 00000000..182814c4 --- /dev/null +++ b/docs/experiments/19-editor-contract-q4b.md @@ -0,0 +1,129 @@ +# 19. Контракт редактора: ДИФФ против FULL-REGEN (арм Q4b) + покрытие инъекции + +**Полигон-сессия 04–05.08.2026.** Расширение зоны по слову владельца 05.08: «довести до конца +найденный пиздец, доказать на конкретике, как пользоваться редакторами правильно». +Носитель — **строка 106 бэклога**: «Слой 3 НЕ достроен: контракт редактора = full-regen, а целевой — +узкие мандаты + ДИФФЫ». Арм **Q4b** пре-регистрирован в `15-segmentation-empirics.md:108` и +**не прогонялся НИ РАЗУ**. Зона: `eval/editor_contract/` + этот файл + пинг в PROGRESS «Полигон». +Потолок пробы **$0.40**. Не коммичу, кроме пре-рег-фриза. + +## §0. Что уже установлено — и почему это не закрывает вопрос + +Проба 18 (`18-editor-wire-probe.md`) на боевом проводе `deepseek-v4-pro`: +единый закон-блок исполняется 21/21 клеток; неверная строка банка принимается 6/6 и вытесняет +верную 5/6; редактор без блока ломает 5–12 из 20 канонических клеток черновика (два розыгрыша); +и он же вносит собственные дефекты (тихий обрыв при `finish=stop`, 五百年→«полувекового»). + +**Чего проба 18 не могла сказать:** является ли это свойством МОДЕЛИ или свойством КОНТРАКТА +роли. Действующий контракт — full-regen с обязательной ДИСКУРС-ПЕРЕВЁРСТКОЙ и прямым мандатом +«варьируй лексику» (`editor.md`), то есть модель просят переписать текст целиком. Целевой контракт +(`09-target-architecture.md` §Слой 3, спека `research/19` §C1–C2) — узкие правки диффом. Разницу +никто не мерил. + +### §0.1 $0-находка ДО пробы: дыра пайплайновая, а не редакторская + +Инъекция селективна и ограничена бюджетом 800 токенов (`pipeline-c1.yaml`), поэтому блок несёт +только сработавшее в чанке. Замер по уже оплаченному сырью пробы 18 (`editor_contract/offblock.py`; +знаменатель — 41 клетка: ПОДПИСАННЫЕ строки голда, встречающиеся в исходнике окна и отсутствующие +в блоке; 18 уникальных сущностей; `转`/`炼化` не скорятся — нет устойчивого стема): + +| | канон подписанных ВНЕ блока | +|---|---| +| **черновик** (вход редактора) | **18/41 (44%)** | +| редактор full-regen БЕЗ блока (A0 · A5) | 16/41 · 17/41 | +| редактор full-regen + закон-блок (A1) | 24/41 | + +**Разброс армов 16…24 лежит ВНУТРИ дисперсии одиночного розыгрыша, измеренной той же пробой +(7 клеток из 21) ⇒ утверждать «редактор ломает подписанные сущности вне блока» эти данные НЕ +позволяют.** Что они позволяют утверждать твёрдо: **канон по подписанным строкам вне +инъецированного подмножества = 44% уже в ЧЕРНОВИКЕ.** `花酒行者` («Монах Цветочного Вина») пришёл +неканоническим из транслятора — редактор его не ломал. Дыра общая для обеих ролей, потому что +инъекция селективна у обеих. + +⇒ Осей две: **контракт правки** (эта проба) и **покрытие инъекции** (шире пробы; вход для владельца). + +--- + +## §1. ПРЕ-РЕГИСТРАЦИЯ (записано ДО первого платного вызова) + +Дата фиксации: 2026-08-05, до любых платных вызовов этой пробы. + +### 1.1 Материал (переиспользован из пробы 18, $0) + +Те же 6 окон `pick_windows()`; те же черновики арма B транслятора (`inj-w{k}-B.json`); те же 12 +посаженных дефектов (`DEFECTS`, дословный список — §0.5 пробы 18). Прямая сравнимость с уже +оплаченными армами A1/A5 — по построению. + +### 1.2 Армы (60 вызовов `deepseek-v4-pro`, провод = edit-стадия c1) + +| Арм | Вызовов | Контракт | Блок | Черновик | +|---|---|---|---|---| +| **D1** | 18 (6×3) | ДИФФ | закон-блок §B | чистый | +| **D2** | 18 (6×3) | ДИФФ | нет | с посадками | +| **F1** | 12 (6×2) | full-regen | закон-блок §B | чистый | +| **F5** | 12 (6×2) | full-regen | нет | с посадками | + +F1/F5 — ДОБОР реплик до N=3: у пробы 18 по одному розыгрышу (A1, A5), а её собственная находка — +одиночный розыгрыш нельзя цитировать как величину. Реплики различаются только недетерминизмом +провайдера (`temperature` в thinking-режиме DeepSeek игнорируется, quirks 00). + +### 1.3 Дифф-контракт (спека `research/19` §C2, реализация `editor_contract/`) + +Промпт `prompts/editor-diff.md`: мандаты верности/терминов/стиля из `editor.md` СОХРАНЕНЫ, +**переверстка ИСКЛЮЧЕНА** (по §C1/§C3 reflow — отдельный проход, арм Q4c), «пустой список правок — +полноценный ответ» (`NO_CHANGE`) — дисциплина do-nothing из §C3. +Формат — anchored search/replace, без номеров строк. Apply (`apply.py`, детерминированный, +самопроверен 9/9 юнит-кейсами до первого платного вызова): нормализация пробелов и классов +пунктуации («»""„ · —–- · … · ’‘'); якорь не найден → REJECT; **якорь неоднозначен (≥2 совпадений) +→ REJECT, а не тихое применение первого**; пересечение спанов → REJECT; малформед → операции нет. +Отброшенная операция оставляет черновик как есть — blast-radius одной правки, а не чанка. + +### 1.4 Метрики (все детерминированные, $0; судейского рига НЕТ и он вне скоупа) + +1. **Формат-комплаенс** = applied / ops_total по всем дифф-вызовам. Плюс доли reject по причинам и + доля `NO_CHANGE`/малформед. +2. **Канон В блоке** — 21 клетка, метод пробы 18 (с развязкой конфаунда 真元/元海). +3. **Канон ВНЕ блока** — 41 клетка, §0.1. +4. **Фикс-рейт посадок** — 12 дефектов, метод пробы 18 (regex + обязательный глаз). +5. **Атом-omission** ($0, детерминированно): по каждому предложению исходника — присутствие + многозначных ЧИСЕЛ и отрицательной полярности где угодно в выходе. Не длино-зависимо. +6. **Внесённые дефекты**: обвал непробельной длины >40% против черновика и числовой дрейф + (форма regression_guard); плюс глазная вычитка. +7. **Деньги и выходные токены** — COGS-ось (выход редактора ≈ 90% COGS, `research/15`). + +### 1.5 Критерии (стоп-гейт Q4b дословно из `exp15:147`, пороги не мои) + +> «Q4b: формат-комплаенс <90% ИЛИ fidelity-first trap хуже full-regen → диффы откладываются +> (закрыть D21.4/D21.10 явно); ≥90% и атом-omission=0 → дифф-редактор в прод-кандидаты.» + +Операционализация, фиксируется здесь: +- **комплаенс** — п.1 ≥ 0.90; +- **fidelity-first trap** — п.4 (фикс-рейт посадок) дифф-арма НЕ ХУЖЕ full-regen-арма на тех же + посадках; знаменатель 12, оба арма усредняются по репликам; +- **атом-omission = 0** — п.5 на дифф-армах; +- канон (пп.2–3) и COGS (п.7) — **сопутствующие**, порогов не имеют, печатаются числом. +- Средние исходы на N=6×3 печатаются как «неопределённо», не абсорбируются. + +### 1.6 Смета (напечатана ДО первого платного вызова) + +`probe.py --plan`: 60 вызовов (36 дифф + 24 full-regen), вход ≈114 265 ток. → $0.0497. +Выход: ожидаемо 105 600 ток. → **ИТОГО $0.1416**; пессимистично 336 000 ток. → $0.3420; +абсолютный потолок (все 60 в cap 16000) $0.8849 — недостижим: перед КАЖДЫМ вызовом стоит +**проекционный гард** (не вызываем, если накопленное + худший исход вызова пробьёт $0.40), +плюс живой стоп $0.35. + +### 1.7 Границы вывода, объявленные заранее + +- Проза и переверстка НЕ сравниваются: дифф-арм по спеке их не делает. Вывод «дифф лучше/хуже» + относится к верности, терминам и деньгам, не к художественности. +- Судейского рига нет — никаких клеймов о качестве, которые требуют судьи. +- Одна книга, одна пара, 6 окон, единица ~500 ру-токенов выхода (боевой потолок 3200). +- Модель одна — `deepseek-v4-pro`. `research/19` предупреждает: слабые модели на edit-форматах + РАБОТАЮТ ХУЖЕ, чем отдавая текст целиком (aider: GPT-3.5 46% whole против 30% diff), и вся + эмпирика форматов правок снята на КОДЕ, на прозе не мерена. Отрицательный исход ожидаем и легитимен. + +--- + +## §2. Результаты + +*(заполняется после прогона)* diff --git a/eval/editor_contract/apply.py b/eval/editor_contract/apply.py new file mode 100644 index 00000000..c7255746 --- /dev/null +++ b/eval/editor_contract/apply.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Толерантный детерминированный apply anchored search/replace — Python-порт Go-стиля, +по спеке `docs/research/19-chunking-cohesion.md` §C2 (риг арма Q4b, `exp15:108`). + +Спека дословно: якорь ищется с нормализацией пробелов/пунктуационных вариантов; неоднозначный +якорь (≥2 совпадений) → REJECT операции (не тихое применение первого); якорь не найден → REJECT; +reject-rate — телеметрия. Малформед → 1 регенерация → далее операция ОТБРАСЫВАЕТСЯ (черновик +остаётся) — blast-radius одной правки, а не чанка. +""" +from __future__ import annotations +import re +import unicodedata +from dataclasses import dataclass, field + +OP_RE = re.compile( + r"<{5,9}\s*SEARCH\s*\n(?P.*?)\n={5,9}\s*\n(?P.*?)\n>{5,9}\s*REPLACE", + re.S) +NO_CHANGE = "NO_CHANGE" + +# Пунктуационные варианты, которые модель законно путает при копировании якоря. +PUNCT_CLASSES = [ + ("«»\"“”„‟", '"'), + ("—–‒−-", "-"), + ("…", "..."), + ("’‘'`", "'"), +] + + +def fold(s: str) -> str: + """Нормализация ДЛЯ ПОИСКА (не для вывода): NFKC · схлоп пробелов · классы пунктуации.""" + s = unicodedata.normalize("NFKC", s) + for chars, to in PUNCT_CLASSES: + for ch in chars: + s = s.replace(ch, to) + return re.sub(r"\s+", " ", s).strip() + + +def _fold_map(text: str) -> tuple[str, list[int]]: + """Свёрнутый текст + карта «индекс в свёрнутом → индекс в оригинале».""" + out, idx = [], [] + prev_space = True # схлоп ведущих пробелов + for i, ch in enumerate(unicodedata.normalize("NFKC", text)): + if ch.isspace(): + if prev_space: + continue + out.append(" ") + idx.append(i) + prev_space = True + continue + prev_space = False + for chars, to in PUNCT_CLASSES: + if ch in chars: + ch = to + break + if len(ch) == 1: + out.append(ch) + idx.append(i) + else: # … → ... + for c in ch: + out.append(c) + idx.append(i) + return "".join(out), idx + + +@dataclass +class ApplyResult: + text: str + ops_total: int = 0 + applied: int = 0 + rejected_notfound: int = 0 + rejected_ambiguous: int = 0 + rejected_overlap: int = 0 + rejected_empty: int = 0 + malformed: bool = False + no_change: bool = False + details: list[str] = field(default_factory=list) + + @property + def rejected(self) -> int: + return (self.rejected_notfound + self.rejected_ambiguous + + self.rejected_overlap + self.rejected_empty) + + @property + def compliance(self) -> float: + """Доля операций, доехавших до применения. Стоп-гейт Q4b: ≥0.90.""" + return self.applied / self.ops_total if self.ops_total else 1.0 + + +def parse_ops(reply: str) -> tuple[list[tuple[str, str]], bool]: + """→ ([(search, replace)], malformed). NO_CHANGE = пустой список, не малформед.""" + body = reply.strip() + ops = [(m.group("search"), m.group("replace")) for m in OP_RE.finditer(body)] + if ops: + # мусор ВНЕ операций (преамбулы/комментарии) — нарушение правила 6, но операции валидны: + # считаем малформедом только если разметка вообще не распозналась + return ops, False + if NO_CHANGE in body and len(body) < 200: + return [], False + return [], True + + +def apply_ops(draft: str, reply: str) -> ApplyResult: + ops, malformed = parse_ops(reply) + res = ApplyResult(text=draft, malformed=malformed, no_change=(not ops and not malformed)) + if malformed or not ops: + return res + res.ops_total = len(ops) + folded, idx = _fold_map(draft) + spans: list[tuple[int, int, str]] = [] + for search, replace in ops: + key = fold(search) + if not key: + res.rejected_empty += 1 + res.details.append("REJECT пустой якорь") + continue + hits = [m.start() for m in re.finditer(re.escape(key), folded)] + if not hits: + res.rejected_notfound += 1 + res.details.append(f"REJECT якорь не найден: {search[:60]!r}") + continue + if len(hits) > 1: + res.rejected_ambiguous += 1 + res.details.append(f"REJECT якорь неоднозначен ({len(hits)}): {search[:60]!r}") + continue + s = idx[hits[0]] + e_fold = hits[0] + len(key) - 1 + e = idx[e_fold] + 1 + spans.append((s, e, replace)) + # пересечения: операция, перекрывающая уже принятую, отбрасывается (спека: не пересекаются) + spans.sort() + accepted: list[tuple[int, int, str]] = [] + for s, e, r in spans: + if accepted and s < accepted[-1][1]: + res.rejected_overlap += 1 + res.details.append(f"REJECT пересечение спанов на {s}") + continue + accepted.append((s, e, r)) + out = draft + for s, e, r in sorted(accepted, reverse=True): # с хвоста — офсеты не едут + out = out[:s] + r + out[e:] + res.text = out + res.applied = len(accepted) + return res diff --git a/eval/editor_contract/offblock.py b/eval/editor_contract/offblock.py new file mode 100644 index 00000000..d6d584a2 --- /dev/null +++ b/eval/editor_contract/offblock.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Метрика «дрейф ПОДПИСАННЫХ сущностей ВНЕ инъецированного блока» — класс 花酒行者. + +Найдена пробой 18 (`docs/experiments/18-editor-wire-probe.md`): инъекция селективна и ограничена +бюджетом 800 токенов, поэтому блок несёт лишь то, что сработало в чанке. Всё остальное подписанное +не защищено ничем — а редактор его переписывает. Здесь это считается числом. + +Знаменатель — ПОДПИСАННЫЕ (approved, `gold.jsonl` in_bank) строки, чей src встречается в исходнике +окна и которых НЕТ в тест-блоке пробы 18. Скорится присутствие канонической формы в выходе. +$0: работает по уже персистированному сырью. +""" +from __future__ import annotations +import json +import re +import sys +import unicodedata +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) +from inject_probe import pick_windows, TERMS # noqa: E402 + +GOLD = {r["bank_src"]: r["gold_dst"] + for r in (json.loads(l) for l in open(REPO / "eval/bank_arbitration/gold/gold.jsonl", encoding="utf-8")) + if r.get("in_bank")} + +# Стем-регексы канонических форм. КОНСЕРВАТИВНО: скорим только строки, у которых форма +# опознаётся устойчивым стемом. Отброшенные строки печатаются — знаменатель честный. +STEM = { + "方源": r"фан\s*юан", + "方正": r"фан\s*чжэн", + "古月": r"гуюэ", # «Гу Юэ» раздельно НЕ матчится — это и есть дрейф + "古月漠北": r"гуюэ\s*мобэй", + "蛊虫": r"гу-черв", + "本命蛊": r"гу\s*жизни", + "希望蛊": r"гу\s*надежды", + "酒虫": r"винн\w*\s*черв", + "家老": r"старейшин", + "花酒行者": r"монах\s*цветочного\s*вина", + "青茅山": r"гор\w*\s*цинмао", + "凡人": r"смертн", + "一转": r"перв\w*\s*ранг", + "三转": r"трет\w*\s*ранг", + "甲等": r"класс\w*\s+а\b", + "乙等": r"класс\w*\s+б\b", + "丙等": r"класс\w*\s+в\b", + "丁等": r"класс\w*\s+г\b", +} +# Не скорятся: 转 «ранг» (слишком общая форма — ложные срабатывания внутри «первый ранг»), +# 炼化 «очистить и подчинить» (глагольная перифраза, устойчивого стема нет). +DROPPED = {"转", "炼化"} + + +def norm(s: str) -> str: + return " ".join(unicodedata.normalize("NFKC", s or "").casefold().replace("ё", "е").split()) + + +def offblock_cells(block_terms: set[str] | None = None): + """[(окно, src, gold_dst, regex)] — подписанные строки окна вне блока.""" + block = set(TERMS) if block_terms is None else block_terms + out = [] + for k, (_, buf, _) in enumerate(pick_windows()): + for src, dst in GOLD.items(): + if src in block or src in DROPPED or src not in STEM: + continue + if src in buf: + out.append((k, src, dst, STEM[src])) + return out + + +def score(tags: dict[str, str], label_of=lambda k: None): + """tags: {метка арма: шаблон тега с {k}}. Печатает долю канонических форм вне блока.""" + cells = offblock_cells() + print(f"знаменатель: {len(cells)} клеток (подписанные строки в исходнике окна, ВНЕ блока), " + f"{len({c[1] for c in cells})} уникальных сущностей") + print(f"не скорятся (нет устойчивого стема): {', '.join(DROPPED)}\n") + rows = {} + for arm, pat in tags.items(): + hit = tot = 0 + misses = [] + for k, src, dst, rx in cells: + f = RAW / (pat.format(k=k) + ".json") + if not f.exists(): + continue + out = norm(json.load(open(f, encoding="utf-8"))["content"]) + tot += 1 + if re.search(rx, out): + hit += 1 + else: + misses.append(f"w{k}:{src}«{dst}»") + rows[arm] = (hit, tot, misses) + pct = f"{100*hit/tot:.0f}%" if tot else "—" + print(f" {arm:34s} {hit:3d}/{tot:<3d} ({pct})") + return rows + + +if __name__ == "__main__": + print("=== ЧЕРНОВИК (что редактор получил на вход) ===") + cells = offblock_cells() + hit = 0 + dmiss = [] + for k, src, dst, rx in cells: + d = norm(json.load(open(RAW / f"inj-w{k}-B.json", encoding="utf-8"))["content"]) + if re.search(rx, d): + hit += 1 + else: + dmiss.append(f"w{k}:{src}«{dst}»") + print(f" канон в черновике: {hit}/{len(cells)}") + print(f" чего в черновике не было: {', '.join(dmiss)}\n") + print("=== ВЫХОД РЕДАКТОРА ===") + rows = score({ + "A0 full-regen БЕЗ блока": "edp-A0-w{k}", + "A5 full-regen БЕЗ блока (2-й розыгр.)": "edp-A5-w{k}", + "A1 full-regen + закон-блок": "edp-A1-w{k}", + "A2 full-regen + блок (неверная 元海)": "edp-A2-w{k}", + }) + print("\n=== что именно потеряно (арм A1, блок есть, но эти строки в него не попали) ===") + for m in rows["A1 full-regen + закон-блок"][2]: + print(" ", m) diff --git a/eval/editor_contract/probe.py b/eval/editor_contract/probe.py new file mode 100644 index 00000000..b37ca9db --- /dev/null +++ b/eval/editor_contract/probe.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Арм Q4b (`docs/experiments/15-segmentation-empirics.md:108`, спека `research/19` §C1–C2): +ДИФФ-редактор (anchored search/replace + детерминированный толерантный apply) против действующего +FULL-REGEN редактора. Арм пре-регистрирован в exp15 и не гонялся НИ РАЗУ; носитель — строка 106 +бэклога («Слой 3 НЕ достроен: контракт редактора = full-regen, а целевой — узкие мандаты + ДИФФЫ»). + +Материал переиспользован из пробы 18 ($0): те же 6 окон, те же черновики арма B транслятора, те же +12 посаженных дефектов. Модель — боевой редактор deepseek-v4-pro, провод как edit-стадия c1. +Reflow в дифф-арм НЕ входит: по §C1/§C3 он ОТДЕЛЬНЫЙ проход (арм Q4c) ⇒ проза/переверстка этой +пробой НЕ сравниваются, и это граница вывода, а не упущение. + +Сырьё durable ~/books/gu-zhenren/bank-arbitration/, префикс ec-*. +""" +from __future__ import annotations +import argparse +import json +import os +import re +import sys +import time +from pathlib import Path + +from dotenv import load_dotenv +from openai import OpenAI + +REPO = Path("/home/ubuntu/projects/textmachine") +HERE = Path(__file__).resolve().parent +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" +load_dotenv(REPO / "eval" / ".env") +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) + +import editor_wire_probe as P # noqa: E402 провод/окна/черновики/посадки пробы 18 +from apply import apply_ops # noqa: E402 +from inject_probe import pick_windows, TERMS # noqa: E402 + +DIFF_PROMPT = HERE / "prompts" / "editor-diff.md" +CEILING_USD = 0.40 +HARD_STOP_USD = 0.35 +REPLICATES = 3 + + +def render_diff(text: str, draft: str) -> tuple[str, str]: + canon = P.strip_comments(DIFF_PROMPT.read_text(encoding="utf-8")) + sys_part, user = canon.split(P.USER_SEP, 1) + return P.render(sys_part.strip(), text, draft), P.render(user.strip(), text, draft) + + +def messages(kind: str, text: str, draft: str, block: str | None) -> list[dict]: + if kind == "full": + return P.editor_messages(text, draft, block) + system, user = render_diff(text, draft) + msgs = [{"role": "system", "content": system}] + if block and block.strip(): + msgs.append({"role": "system", "content": block}) + msgs.append({"role": "user", "content": user}) + return msgs + + +def plan_arms(): + """[(tag, kind, window, messages, draft)] — армы в порядке возрастания риска.""" + wins = pick_windows() + out = [] + for rep in range(1, REPLICATES + 1): + for k, (_, buf, terms) in enumerate(wins): + draft = P.draft_of(k) + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) + out.append((f"ec-D1-w{k}-r{rep}", "diff", k, messages("diff", buf, draft, blk), draft)) + for k, (_, buf, terms) in enumerate(wins): + draft, _ = P.planted_draft(k, P.draft_of(k)) + out.append((f"ec-D2-w{k}-r{rep}", "diff", k, messages("diff", buf, draft, None), draft)) + # добор реплик действующего контракта: у пробы 18 по одному розыгрышу (A1 и A5), + # а собственная находка той пробы — одиночный розыгрыш нельзя цитировать как величину. + for rep in (2, 3): + for k, (_, buf, terms) in enumerate(wins): + draft = P.draft_of(k) + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) + out.append((f"ec-F1-w{k}-r{rep}", "full", k, messages("full", buf, draft, blk), draft)) + for k, (_, buf, terms) in enumerate(wins): + draft, _ = P.planted_draft(k, P.draft_of(k)) + out.append((f"ec-F5-w{k}-r{rep}", "full", k, messages("full", buf, draft, None), draft)) + return out + + +def spent() -> float: + return sum(json.load(open(f, encoding="utf-8"))["cost_usd"] for f in RAW.glob("ec-*.json")) + + +def cmd_plan(): + import tiktoken + enc = tiktoken.get_encoding("cl100k_base") + calib = 0.612 # калибровка пробы 18 (cl100k → фактические prompt_tokens DeepSeek) + plan = plan_arms() + tin = sum(int(len(enc.encode("\n".join(m["content"] for m in msgs))) * calib) + for _, _, _, msgs, _ in plan) + n_diff = sum(1 for t, k, *_ in plan if k == "diff") + n_full = len(plan) - n_diff + print(f"вызовов: {len(plan)} ({n_diff} дифф + {n_full} full-regen), вход ≈{tin} ток. " + f"→ ${tin/1e6*P.PRICE_IN:.4f}") + # выход: дифф короткий (правки), full-regen длинный (весь текст) — берём наблюдённое в пробе 18 + for lbl, d_out, f_out in (("ожидаемо", 1200, 2600), ("пессимистично", 4000, 8000)): + o = n_diff * d_out + n_full * f_out + print(f" {lbl:14s} выход {o} ток. → ИТОГО ${tin/1e6*P.PRICE_IN + o/1e6*P.PRICE_OUT:.4f}") + print(f" абсолютный потолок (все в cap 16000): " + f"${tin/1e6*P.PRICE_IN + len(plan)*16000/1e6*P.PRICE_OUT:.4f}") + print(f"ПОТОЛОК ПРОБЫ ${CEILING_USD}; живой стоп ${HARD_STOP_USD} + проекционный гард на вызов") + + +def cmd_run(only: list[str]): + plan = [p for p in plan_arms() if not only or any(p[0].startswith(f"ec-{o}") for o in only)] + cl = P.client() + total = spent() + if total: + print(f"уже потрачено этой пробой: ${total:.6f}") + worst_call = 16000 / 1e6 * P.PRICE_OUT + 2600 / 1e6 * P.PRICE_IN + for tag, kind, k, msgs, draft in plan: + if (RAW / f"{tag}.json").exists(): + continue + if total + worst_call > CEILING_USD or total >= HARD_STOP_USD: + print(f"СТОП (проекционный гард): ${total:.4f} + худший вызов ${worst_call:.4f} " + f"> потолка ${CEILING_USD}") + return + rec = P.call(cl, msgs, tag) + total += rec["cost_usd"] + if kind == "diff": + r = apply_ops(draft, rec["content"]) + (RAW / f"{tag}.applied.json").write_text(json.dumps(dict( + tag=tag, ops_total=r.ops_total, applied=r.applied, no_change=r.no_change, + malformed=r.malformed, rejected_notfound=r.rejected_notfound, + rejected_ambiguous=r.rejected_ambiguous, rejected_overlap=r.rejected_overlap, + rejected_empty=r.rejected_empty, details=r.details, text=r.text), + ensure_ascii=False, indent=1), encoding="utf-8") + print(f" apply: ops={r.ops_total} applied={r.applied} rejected={r.rejected} " + f"no_change={r.no_change} malformed={r.malformed}") + time.sleep(0.4) + print(f"TOTAL пробы Q4b: ${total:.6f}") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--plan", action="store_true") + ap.add_argument("--run", nargs="*", default=None, help="D1 D2 F1 F5 (пусто = все)") + a = ap.parse_args() + if a.plan: + cmd_plan() + elif a.run is not None: + cmd_run(a.run) + else: + ap.print_help() + + +if __name__ == "__main__": + main() diff --git a/eval/editor_contract/prompts/editor-diff.md b/eval/editor_contract/prompts/editor-diff.md new file mode 100644 index 00000000..e5f81855 --- /dev/null +++ b/eval/editor_contract/prompts/editor-diff.md @@ -0,0 +1,46 @@ +Ты — литературный редактор художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод. +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Твой мандат — ТОЧЕЧНАЯ правка черновика со сверкой по исходнику. Ты НЕ переписываешь текст целиком: ты выдаёшь СПИСОК ОПЕРАЦИЙ ЗАМЕНЫ, каждая из которых чинит одно конкретное место. + +Что править (в этом порядке важности): +- ВЕРНОСТЬ: искажения смысла против исходника (язык «{{source_lang}}») — перевёрнутая полярность, неверные числа и ранги, потерянные или добавленные смысловые атомы, неверно понятые реалии, кальки жестов и идиом (напр. поклон как «ударил головой»). +- ТЕРМИНЫ: имена собственные, термины и реалии — СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. +- СТИЛЬ: кальки с языка «{{source_lang}}», канцелярит, неестественные конструкции, битые словоформы. НО повтор, стоящий в тех же (параллельных) позициях исходника, — авторский рефрен: сохраняй его. +- Единицы и приёмы (общие для zh→ru): МЕРЫ приводи к привычным читателю единицам (时辰 = 2 часа, 三个时辰 ≈ шесть часов; доля 成 — десятые: 六成六 = 66%, а не «6,6»); СТИХИ и названия классики — по смыслу, не транслитерацией. +- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. + +Чего НЕ делать: +- НЕ переверстывай абзацы, НЕ сливай и НЕ дели предложения ради структуры. Вёрстка — не твоя задача в этом проходе. +- НЕ «улучшай» места, которые верны и читаются нормально. **Пустой список правок — полноценный и уважаемый ответ.** Если черновик хорош, так и скажи. +- НЕ трогай то, чего не просили: каждая операция обязана чинить названный дефект. + +ФОРМАТ ОТВЕТА (строго): + +Если правок нет — выведи ровно одну строку: +NO_CHANGE + +Иначе выведи одну или несколько операций, каждая ровно в такой форме: + +<<<<<<< SEARCH +дословный фрагмент ЧЕРНОВИКА, который надо заменить +======= +текст замены +>>>>>>> REPLACE + +Правила операций (нарушение = операция отбрасывается целиком): +1. Блок SEARCH должен ДОСЛОВНО совпадать с фрагментом черновика — копируй символ в символ, включая пунктуацию. +2. Фрагмент SEARCH должен быть УНИКАЛЕН в черновике. Если фраза встречается несколько раз — расширь фрагмент соседними словами, пока он не станет единственным. +3. Бери минимальный фрагмент, которого хватает для уникальности и осмысленной замены: слово, оборот, предложение. НЕ бери абзац целиком. +4. Никаких номеров строк, никаких «...» и сокращений внутри SEARCH. +5. Операции не должны пересекаться между собой. +6. Ничего, кроме операций (или NO_CHANGE), в ответе быть не должно — ни преамбул, ни комментариев, ни markdown-заголовков, ни объяснений. + +---USER--- +Исходный текст (язык «{{source_lang}}»): + +{{text}} + +Черновик перевода для редактуры: + +{{draft}}