#!/usr/bin/env python3
"""WS2 (г) verification — chunking + edit-unit simulation on the 25-chapter book.

Reconstructs per-chapter source from rerun/records.json, then simulates:
  (1) DRAFT chunking under the OUTPUT-token budget (fertility est_out), for target_out
      in {1500, 1797, 2200}, vs the CURRENT input-est greedy budget (targetChunkTokens=1500);
  (2) EDIT-UNIT = chapter (with paragraph-split when a chapter exceeds a ceiling).
Reports: est_out histograms, share in the 2k-8k-out degradation zone (research/19 §A.1.1),
floor-invariant (chunks under MinChunkChars=500 src chars), chapters>ceiling, chunk/unit counts,
and a COGS forecast via fertility (flash draft + glm-5 edit).

$0: deterministic, no LLM, no network. Reuses eval/exp15/chunker.py greedy+sentence helpers.
Writes /home/ubuntu/books/gu-zhenren/design11/ws2_chunk_sim.json (OUT of git).
"""
import json, os, sys
from collections import defaultdict, Counter

sys.path.insert(0, "/home/ubuntu/projects/textmachine/eval/exp15")
import chunker as CK   # greedy_chunks, est_out, _est_tokens_from, _class_counts, _pack_sentences, split_paragraphs, normalize_source

RECORDS = "/home/ubuntu/books/gu-zhenren/rerun/records.json"
OUT = "/home/ubuntu/books/gu-zhenren/design11/ws2_chunk_sim.json"
F_CJK, F_OTHER = 1.1978, 0.3852
MIN_CHUNK_CHARS = 500          # Gates.Coverage.MinChunkChars (pipeline.go:159)
DEG_LO, DEG_HI = 2000, 8000    # emission degradation zone, output tokens (research/19 §A.1.1)
# prices USD/1M (freeze_facts / vendor-verified): draft flash out, edit glm-5 out (thinking-off)
PRICE = {"flash_in": 0.14, "flash_cache": 0.0028, "flash_out": 0.28,
         "glm5_in": 1.00, "glm5_cache": 0.20, "glm5_out": 3.20}

def est_out(text):
    return CK.est_out(text)  # F_CJK*cjk + F_OTHER*other on source chars

def build_chapters():
    recs = json.load(open(RECORDS))
    by_ch = defaultdict(list)
    for r in recs:
        by_ch[r["chapter"]].append((r["chunk_idx"], r.get("source", "") or ""))
    chapters = {}
    for ch, items in by_ch.items():
        items.sort()
        chapters[ch] = "\n".join(s for _, s in items)
    return dict(sorted(chapters.items()))

def greedy_out(text, target_out):
    """Greedy paragraph pack to an OUTPUT-token budget (fertility est_out), mirroring
    appendChapterChunks but with the budget in ru-output tokens. Oversize paragraph ->
    sentence pack; oversize sentence -> own chunk (never split a sentence)."""
    chapter = CK.normalize_source(text)
    paras = CK.split_paragraphs(chapter)
    chunks, buf = [], []
    def buf_out():
        return est_out("\n\n".join(buf)) if buf else 0.0
    def flush():
        nonlocal buf
        if buf:
            t = "\n\n".join(buf).strip()
            if t:
                chunks.append(t)
        buf = []
    for p in paras:
        if est_out(p) > target_out:
            flush()
            # sentence pack this oversize paragraph to target_out
            segs = CK.split_source_sentences(p)
            sbuf = []
            def sflush():
                nonlocal sbuf
                if sbuf:
                    chunks.append("".join(sbuf))
                sbuf = []
            for s in segs:
                if sbuf and est_out("".join(sbuf) + s) > target_out:
                    sflush()
                sbuf.append(s)
            sflush()
            continue
        if buf and est_out("\n\n".join(buf) + "\n\n" + p) > target_out:
            flush()
        buf.append(p)
    flush()
    return chunks

def nonspace_chars(s):
    return sum(1 for c in s if not c.isspace())

def hist(values, edges):
    c = Counter()
    for v in values:
        placed = False
        for lo, hi in edges:
            if lo <= v < hi:
                c[f"{lo}-{hi}"] += 1; placed = True; break
        if not placed:
            c[f">={edges[-1][1]}"] += 1
    return dict(c)

def summarize_chunks(chunks):
    outs = [est_out(c) for c in chunks]
    src_chars = [nonspace_chars(c) for c in chunks]
    return {
        "n_chunks": len(chunks),
        "est_out_mean": round(sum(outs)/len(outs), 1) if outs else 0,
        "est_out_min": round(min(outs), 1) if outs else 0,
        "est_out_max": round(max(outs), 1) if outs else 0,
        "under_min_chunk_chars(500)": sum(1 for s in src_chars if s < MIN_CHUNK_CHARS),
        "in_degradation_zone_2k_8k": sum(1 for o in outs if DEG_LO <= o <= DEG_HI),
        "above_deg_hi(8k)": sum(1 for o in outs if o > DEG_HI),
        "hist_est_out": hist(outs, [(0,1000),(1000,2000),(2000,3200),(3200,5000),(5000,8000)]),
        "total_est_out": round(sum(outs)),
    }

def main():
    chapters = build_chapters()
    result = {"n_chapters": len(chapters), "MIN_CHUNK_CHARS": MIN_CHUNK_CHARS,
              "degradation_zone_out_tokens": [DEG_LO, DEG_HI]}

    # --- (a) CURRENT chunking: greedy on input-est targetChunkTokens=1500 (chunker.go behavior) ---
    cur_chunks = []
    for ch, txt in chapters.items():
        cur_chunks += CK.greedy_chunks(txt)   # input-est 1500 budget (backend const)
    result["current_greedy_input_est_1500"] = summarize_chunks(cur_chunks)

    # --- (b) DRAFT chunking under OUTPUT-token budget (fertility), swept target_out ---
    result["draft_output_budget"] = {}
    for tgt in (1500, 1797, 2200):
        dchunks = []
        for ch, txt in chapters.items():
            dchunks += greedy_out(txt, tgt)
        result["draft_output_budget"][f"target_out_{tgt}"] = summarize_chunks(dchunks)

    # --- (c) EDIT-UNIT = chapter; paragraph-split when chapter est_out > ceiling ---
    for ceiling in (8000, 3200):  # 8000 = gated large-chapter arm; 3200 = ratified default (review-2 F1)
        units = []
        chapters_over = 0
        split_units = 0
        for ch, txt in chapters.items():
            co = est_out(txt)
            if co <= ceiling:
                units.append(txt)
            else:
                chapters_over += 1
                # split at paragraph boundaries greedily to <= ceiling (never split a paragraph)
                sub = greedy_out(txt, ceiling)
                split_units += len(sub)
                units += sub
        s = summarize_chunks(units)
        s["chapters_over_ceiling"] = chapters_over
        s["units_from_split_chapters"] = split_units
        s["chapter_est_out_raw"] = {str(ch): round(est_out(txt)) for ch, txt in chapters.items()}
        result[f"edit_unit_chapter_ceiling_{ceiling}"] = s

    # --- (d) COGS forecast per structure (input via records source, output via fertility) ---
    # draft: input = source est-tokens; output = est_out. edit: input = source+draft (~2x src) est; output ~= 0.85*draft_out (edit shrinks slightly)
    total_src_est_in = sum(CK._est_tokens_from(*CK._class_counts(txt)) for txt in chapters.values())
    total_draft_out = sum(est_out(txt) for txt in chapters.values())
    # naive per-book COGS on the whole book (no cache) as an upper bound; cache handled in WS1/WS6
    draft_in_cost = total_src_est_in * PRICE["flash_in"] / 1e6
    draft_out_cost = total_draft_out * PRICE["flash_out"] / 1e6
    edit_in_est = (total_src_est_in + total_draft_out)  # editor sees src + draft
    edit_in_cost = edit_in_est * PRICE["glm5_in"] / 1e6
    edit_out_cost = (total_draft_out * 0.9) * PRICE["glm5_out"] / 1e6  # edit ~= draft length
    result["cogs_forecast_25ch_no_cache_upper_bound"] = {
        "total_src_est_in_tokens": round(total_src_est_in),
        "total_draft_out_tokens": round(total_draft_out),
        "draft_cost_usd": round(draft_in_cost + draft_out_cost, 4),
        "edit_cost_usd": round(edit_in_cost + edit_out_cost, 4),
        "total_usd": round(draft_in_cost + draft_out_cost + edit_in_cost + edit_out_cost, 4),
        "note": "upper bound, no prefix cache; WS1/WS6 apply warm-then-fan cache (-80% cached input)",
    }

    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    json.dump(result, open(OUT, "w"), ensure_ascii=False, indent=2)
    print(json.dumps(result, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    main()
