Freeze polygon pre-registration and rig for the Q4b diff-editor arm before any paid call
This commit is contained in:
parent
6a0233ff53
commit
52e12f311e
5 changed files with 591 additions and 0 deletions
129
docs/experiments/19-editor-contract-q4b.md
Normal file
129
docs/experiments/19-editor-contract-q4b.md
Normal file
|
|
@ -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. Результаты
|
||||
|
||||
*(заполняется после прогона)*
|
||||
143
eval/editor_contract/apply.py
Normal file
143
eval/editor_contract/apply.py
Normal file
|
|
@ -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<search>.*?)\n={5,9}\s*\n(?P<replace>.*?)\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
|
||||
120
eval/editor_contract/offblock.py
Normal file
120
eval/editor_contract/offblock.py
Normal file
|
|
@ -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)
|
||||
153
eval/editor_contract/probe.py
Normal file
153
eval/editor_contract/probe.py
Normal file
|
|
@ -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()
|
||||
46
eval/editor_contract/prompts/editor-diff.md
Normal file
46
eval/editor_contract/prompts/editor-diff.md
Normal file
|
|
@ -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}}
|
||||
Loading…
Add table
Reference in a new issue