File size: 20,584 Bytes
9644d0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
"""
eval/runner.py β€” Subprocess runner for 4B evaluation
====================================================

Invoked by run_4b_eval.py once per prompt. Loads the model, applies
the cleaned ACTIVE_MANIFOLD patch, generates tokens, collects PX
telemetry, writes one JSON, exits. The process-exit pattern guarantees
full VRAM cleanup between prompts.

This module is self-contained within all_space/. It imports from:
  - config.py              (MODEL_REGISTRY)
  - model_manager.py       (_migrate_preset)
  - px_patches.gemma4_2b_px.patch  (apply_px_patch, get_px_metrics)

NEVER imports from dmt_space_50/ or any other legacy code path.

Usage (as subprocess, JSON config on argv[1]):
    python eval/runner.py /path/to/prompt_config.json
"""

import json
import math
import os
import sys
import time
import gc

# Project root on path
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
    sys.path.insert(0, _ROOT)

import torch
from transformers import AutoTokenizer

from config import MODEL_REGISTRY
from model_manager import _migrate_preset


# ═══════════════════════════════════════════════════════════════════════════════
# Prompt-set (inline β€” avoids dmt_space_50 dependency)
# ═══════════════════════════════════════════════════════════════════════════════

PROMPTS = {
    "math": [
        "What is 17 * 23?",
        "Solve for x: 2x + 5 = 17",
        "What is the square root of 144?",
        "Calculate 15% of 240.",
        "If a triangle has sides 3, 4, 5, what is its area?",
        "What is the derivative of x^2 + 3x?",
        "Compute the sum 1+2+...+100.",
        "What is 7 factorial?",
        "Solve: 3^4 = ?",
        "How many degrees are in a triangle?",
        "What is the value of pi to 2 decimal places?",
        "If f(x) = 2x + 1, what is f(5)?",
        "What is 1024 / 32?",
        "What is the next prime after 13?",
        "What is 2^10?",
        "Solve 5x = 25.",
        "What is 0.25 as a fraction?",
        "What is the perimeter of a square with side 7?",
        "What is 9 squared?",
        "What is 1000 - 437?",
    ],
    "logic": [
        "If all roses are flowers, and some flowers fade quickly, can we conclude that some roses fade quickly?",
        "What comes next in the sequence: 1, 1, 2, 3, 5, 8, ?",
        "If A implies B, and B implies C, what does A imply?",
        "Is the statement 'I am lying' a paradox?",
        "What is the contrapositive of 'If P then Q'?",
        "If today is Wednesday, what day will it be 10 days from now?",
        "All cats are mammals. Felix is a cat. What can we conclude?",
        "If some birds cannot fly, can we say all birds fly?",
        "What is the missing number: 2, 4, 8, 16, ?, 64",
        "If all Zorps are Frims, and no Frim is a Glip, can a Zorp be a Glip?",
        "What is the logical negation of 'All swans are white'?",
        "If it rains, the ground gets wet. The ground is wet. Did it rain?",
        "Complete: 1, 4, 9, 16, ?",
        "If A and B are both true, what is the truth value of A or B?",
        "What is the modus ponens form?",
        "If the butler and the gardener both claim innocence, and only one is lying, who did it?",
        "What is the difference between necessary and sufficient conditions?",
        "If P is false, what is the truth value of 'P or Q'?",
        "Complete: J, F, M, A, M, ?",
        "What is the syllogism called when the conclusion is hidden in the premises?",
    ],
    "creative": [
        "Write a haiku about a forgotten robot.",
        "Describe the color of silence to someone who has never seen.",
        "Invent a word for the feeling of a Sunday afternoon in autumn.",
        "What if gravity reversed for one hour each day?",
        "Write the opening line of a novel set inside a dream.",
        "Describe a city built entirely of music.",
        "What would a conversation between two shadows look like?",
        "Invent a new color and describe its emotional weight.",
        "Write a short poem about the last star going out.",
        "Describe a library where the books are alive.",
        "What does loneliness sound like as a piece of music?",
        "Imagine a door that opens onto yesterday.",
        "Write a toast given at the end of the universe.",
        "Describe a tree that grows dreams instead of leaves.",
        "What is the smell of a forgotten promise?",
        "Invent a holiday and describe how it is celebrated.",
        "Write three sentences about a snowflake that refuses to melt.",
        "Describe the texture of a lie.",
        "What would a museum of lost thoughts look like?",
        "Imagine a letter written by a river to the sea.",
    ],
    "synthesis": [
        "What is the relationship between mathematics and music?",
        "How do literature and computer science inform each other?",
        "Compare the structure of a symphony to the structure of a programming language.",
        "What can physics learn from biology?",
        "How does the architecture of a city reflect the values of its culture?",
        "What is the connection between memory and identity?",
        "How do cooking and chemistry relate?",
        "Compare a forest ecosystem to a market economy.",
        "What can dance teach us about mathematics?",
        "How does the structure of DNA relate to information theory?",
        "What is the relationship between sleep and creativity?",
        "How do rivers shape civilizations?",
        "Compare the role of ritual in religion and in software development.",
        "What is the connection between color theory and emotional states?",
        "How does the structure of a cell resemble a city?",
        "What is the relationship between language and thought?",
        "How do games and stories share narrative structure?",
        "What can the study of crystals teach us about patterns in music?",
        "How do economic systems and ecological systems balance?",
        "What is the connection between a poem and a mathematical proof?",
    ],
}


# ═══════════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════════

def shannon_entropy(weights_dict):
    """Shannon entropy of zone weights (H = -Ξ£ p log p)."""
    vals = list(weights_dict.values()) if weights_dict else []
    total = sum(vals)
    if total < 1e-10:
        return 0.0
    probs = [v / total for v in vals]
    return -sum(p * math.log2(p) for p in probs if p > 0)


def token_diversity(input_ids):
    """Type-token ratio: distinct tokens / total tokens."""
    if input_ids is None or len(input_ids) == 0:
        return 0.0
    return len(set(input_ids.tolist())) / max(1, len(input_ids))


# ═══════════════════════════════════════════════════════════════════════════════
# Calibrator warmup
# ═══════════════════════════════════════════════════════════════════════════════

# Per-scale warmup defaults for AutoCalibrator seeding. Derived from
# empirical kurtosis distributions measured in the live SR-59i runs:
#   - 270M:  K β‰ˆ 200..450, mean β‰ˆ 313, std β‰ˆ 25
#   - 1B:    K β‰ˆ 1100..1130, mean β‰ˆ 1115, std β‰ˆ 5
#   - 4B:    K β‰ˆ 2100..2450, mean β‰ˆ 2280, std β‰ˆ 85
#   - E2B:   K β‰ˆ 500..760, mean β‰ˆ 620, std β‰ˆ 85 (multimodal gemma4)
# Jitter must be β‰ˆempirical std. Too large β†’ all inputs become
# Β±huge-sigma outliers and routing collapses to one zone.
_SCALE_WARMUP_DEFAULTS = {
    "gemma3-270m-it": {"seed": 313.0,  "jitter": 40.0},
    "gemma3-1b-it":   {"seed": 1115.0, "jitter": 5.0},
    "gemma3-4b-it":   {"seed": 2280.0, "jitter": 15.0},
    "gemma4-e2b-it":  {"seed": 620.0,  "jitter": 5.0},
    "default":        {"seed": 1000.0, "jitter": 5.0},
}


def _calibrator_warmup(model, n_warmup=5, kurtosis_seed=2400.0, kurtosis_jitter=85.0):
    """SR-61 routing-collapse fix: bypass the cold-start of AutoCalibrator.

    Each subprocess starts with `_online_n=0`, so the FIRST get_zone_weights
    call returns z=0.0 for every input (because k_mean is None and the
    `_online_n < ONLINE_WARMUP=5` branch in _get_z_score returns 0.0).
    Result: bit-identical zone_weights across all 80 prompts.

    We solve this without paying the cost of 5 real forward-passes by
    pre-seeding the AutoCalibrator's online state with a synthetic but
    plausible distribution centered on `kurtosis_seed` with realistic
    `kurtosis_jitter` (the empirical 4B regime is Kβ‰ˆ2400 Β± 85).

    CRITICAL: jitter must be realistic, not huge. If jitter=300, the
    online std is ~300, so z-scores for any real input become Β±3+Οƒ
    outliers. In that regime, _get_kurtosis_weights returns W < 0.05
    and falls back to _adaptive_phi_weights(phi) β€” which is identical
    for all prompts that share the same phi. Use jitter β‰ˆ empirical_std.

    After seeding, _get_z_score's `_online_n >= ONLINE_WARMUP` branch is
    taken and uses the online mean/std to compute discriminative
    z-scores, breaking the collapse.

    This is identical to what would happen organically if 5 real prompts
    had been seen β€” we're just skipping the warmup period.
    """
    # Resolve the text model (multimodal: model.model.language_model;
    # text-only: model.model)
    inner = getattr(model, "model", model)
    if hasattr(inner, "language_model"):
        inner = inner.language_model
    cal = getattr(inner, "_px_calibrator", None)
    if cal is None:
        print("[runner] no _px_calibrator found β€” skipping warmup", file=sys.stderr)
        return

    # SR-61b: Check if manifold was already loaded by AutoCalibrator.__init__
    if cal.calibrated:
        print(f"[runner] persistent manifold loaded for {cal.model_id} β€” skipping synthetic warmup", file=sys.stderr)
        return

    import random
    rng = random.Random(0xC0DE)  # deterministic across runs
    samples = [
        kurtosis_seed + rng.uniform(-kurtosis_jitter, kurtosis_jitter)
        for _ in range(n_warmup)
    ]
    # Welford seeding: we need mean and M2 of these samples
    n = len(samples)
    mean = sum(samples) / n
    m2 = sum((x - mean) ** 2 for x in samples)

    cal._online_n = n
    cal._online_k_mean = mean
    cal._online_k_m2 = m2
    # Also seed the calibration k_mean/k_std: the routing_std cap at line
    # 380 of auto_tune.py uses `cal_std * 2.0` where cal_std = max(self.k_std,
    # MIN_ONLINE_K_STD). If k_std is None (the default for HS=2560/1536),
    # cal_std falls back to MIN_ONLINE_K_STD=1.0, capping routing_std at 2.0.
    # That makes every kurtosis value a Β±huge-z outlier, which collapses
    # all Gaussian weights to one zone. Seed both: the calibrated mean/std
    # AND the online mean/std.
    cal.k_samples = samples
    cal.calibrate()  # This sets k_mean, k_std, and the CRITICAL zone_temperature
    
    std = math.sqrt(m2 / max(n - 1, 1)) if n > 1 else 1.0
    print(f"[runner] calibrator seeded and calibrated: n={n}, k_mean={mean:.1f}, "
          f"k_std={std:.1f}, cal_k_std={kurtosis_jitter:.1f}, T={cal.zone_temperature:.2f}", 
          file=sys.stderr)


# ═══════════════════════════════════════════════════════════════════════════════
# Subprocess entry
# ═══════════════════════════════════════════════════════════════════════════════

def _run_one_prompt(prompt_text, model_id, preset, max_new_tokens, result_path):
    """Load model, generate, collect telemetry, write JSON. Run in a fresh
    subprocess so VRAM is fully released between prompts.
    """
    # Config safety net
    preset = _migrate_preset(preset)

    registry = MODEL_REGISTRY[model_id]
    hf_id = registry["hf_id"]
    tok_id = registry["tokenizer_id"]
    dtype = getattr(torch, registry["dtype"])
    model_type = registry.get("model_type", "gemma3")
    patch_dir = registry.get("patch_dir")
    patch_kwargs = dict(registry.get("patch_kwargs", {}))

    # ── Load tokenizer ──
    tokenizer = AutoTokenizer.from_pretrained(tok_id)
    if registry.get("chat_template_manual"):
        tokenizer.chat_template = registry["chat_template_manual"]

    # ── Load model (bf16 + use_cache=False to fit in 12GB VRAM) ──
    if model_type == "gemma3_conditional":
        from transformers import Gemma3ForConditionalGeneration
        model = Gemma3ForConditionalGeneration.from_pretrained(
            hf_id, torch_dtype=dtype, device_map="auto",
        )
    elif model_type == "gemma4_conditional":
        from transformers import AutoModelForImageTextToText
        model = AutoModelForImageTextToText.from_pretrained(
            hf_id, torch_dtype=dtype, device_map="auto", trust_remote_code=True,
        )
    else:
        from transformers import AutoModelForCausalLM
        model = AutoModelForCausalLM.from_pretrained(
            hf_id, torch_dtype=dtype, device_map="auto",
        )

    # ── Apply PX patch ──
    if patch_dir is not None and preset != "BASELINE":
        if patch_dir == "gemma4_2b_px":
            from px_patches.gemma4_2b_px.patch import apply_px_patch
        elif patch_dir == "gemma3_270m_px_baseline":
            from px_patches.gemma3_270m_px_baseline.patch import apply_px_patch
        else:
            from px_patches.minicpm5_1b_px.patch import apply_px_patch

        patch_kwargs["config_preset"] = preset
        apply_px_patch(model, **patch_kwargs)
        print(f"[runner] {model_id} patched with {preset}", file=sys.stderr)

        # ── Warmup the AutoCalibrator ──
        # SR-61 routing-collapse fix: a fresh subprocess starts with
        # _online_n=0, so the FIRST get_zone_weights call returns z=0.0 for
        # every input (because k_mean is None until ONLINE_WARMUP=5 samples
        # have been collected). This produces bit-identical zone_weights
        # across all 80 prompts.
        # We pre-seed the AutoCalibrator's online + calibration stats with
        # scale-appropriate synthetic samples. Jitter must be realistic
        # (β‰ˆempirical std) β€” too large a jitter makes z-scores Β±huge-sigma
        # outliers and collapses all weights to one zone.
        warmup_cfg = _SCALE_WARMUP_DEFAULTS.get(model_id, _SCALE_WARMUP_DEFAULTS["default"])
        _calibrator_warmup(model, n_warmup=10,
                           kurtosis_seed=warmup_cfg["seed"],
                           kurtosis_jitter=warmup_cfg["jitter"])
        print(f"[runner] calibrator warmed up (n=10, seed={warmup_cfg['seed']}, "
              f"jitter={warmup_cfg['jitter']})", file=sys.stderr)

    # ── Tokenize ──
    messages = [{"role": "user", "content": prompt_text}]
    try:
        input_text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
    except Exception:
        # Base model with no chat template
        input_text = prompt_text

    inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
    input_ids = inputs["input_ids"][0]
    input_len = input_ids.shape[0]

    # ── Generate (use_cache=False for 4B KV-cache dimension stability) ──
    # SR-61 termination fix: explicitly set eos_token_id and pad_token_id
    # and inject PX-specific kwargs like repetition_penalty.
    t0 = time.time()
    
    # Extract PX-specific kwargs from the model attributes
    from generators import _px_gen_kwargs
    gen_kwargs = {
        "max_new_tokens": max_new_tokens,
        "do_sample": False,
        "temperature": 1.0,
        "use_cache": False,
        "eos_token_id": tokenizer.eos_token_id,
        "pad_token_id": tokenizer.eos_token_id,
    }
    gen_kwargs = _px_gen_kwargs(model, gen_kwargs)
    
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            **gen_kwargs
        )
    gen_time = time.time() - t0

    new_tokens = outputs[0][input_len:]
    completion_text = tokenizer.decode(new_tokens, skip_special_tokens=True)
    completion_len = len(new_tokens)

    # ── Telemetry ──
    if patch_dir is not None and preset != "BASELINE":
        # Use the same get_px_metrics that gemma4 now exposes
        if patch_dir == "gemma4_2b_px":
            from px_patches.gemma4_2b_px.patch import get_px_metrics
        elif patch_dir == "gemma3_270m_px_baseline":
            from px_patches.gemma3_270m_px_baseline.patch import get_px_metrics
        else:
            from px_patches.minicpm5_1b_px.patch import get_px_metrics
        try:
            metrics = get_px_metrics(model)
        except Exception as e:
            print(f"[runner] get_px_metrics failed: {e}", file=sys.stderr)
            metrics = {}
    else:
        metrics = {}

    zone_weights = metrics.get("zone_weights", {}) or {}
    zone_entropy = shannon_entropy(zone_weights)
    phi = metrics.get("phi", 1.0)
    if hasattr(phi, "item"):
        phi = phi.item()
    phi = float(phi)
    zone_name = metrics.get("zone", "UNKNOWN")
    cognitive_signature = metrics.get("cognitive_signature", {}) or {}
    kurtosis = cognitive_signature.get("kurtosis", None)
    if hasattr(kurtosis, "item"):
        kurtosis = kurtosis.item()
    td = token_diversity(input_ids)

    result = {
        "prompt": prompt_text,
        "completion": completion_text,
        "model_id": model_id,
        "preset": preset,
        "completion_tokens": completion_len,
        "input_tokens": input_len,
        "gen_time_sec": gen_time,
        "phi": phi,
        "zone": zone_name,
        "zone_weights": {k: float(v) for k, v in zone_weights.items()} if zone_weights else {},
        "zone_entropy": zone_entropy,
        "kurtosis": float(kurtosis) if kurtosis is not None else None,
        "token_diversity_input": td,
        "loops_run": metrics.get("steps", 0),
        "entropy": metrics.get("entropy", 0.0),
    }

    with open(result_path, "w") as f:
        json.dump(result, f, indent=2)

    print(f"[runner] {model_id} {preset} | phi={phi:.3f} | H={zone_entropy:.3f} | "
          f"zone={zone_name} | {completion_len}tok | {gen_time:.1f}s",
          file=sys.stderr)

    # Explicit cleanup (defense in depth β€” subprocess exit is the main release)
    del model, tokenizer, inputs, outputs
    gc.collect()
    if torch.cuda.is_available():
        torch.cuda.empty_cache()
    return 0


# ═══════════════════════════════════════════════════════════════════════════════
# CLI entry
# ═══════════════════════════════════════════════════════════════════════════════

def main():
    if len(sys.argv) < 2:
        print("usage: python eval/runner.py <config.json>", file=sys.stderr)
        sys.exit(1)
    with open(sys.argv[1]) as f:
        cfg = json.load(f)

    prompt_text = cfg["prompt"]
    model_id = cfg["model_id"]
    preset = cfg.get("preset", "ACTIVE_MANIFOLD")
    max_new_tokens = cfg.get("max_new_tokens", 30)
    result_path = cfg["result_path"]

    return _run_one_prompt(prompt_text, model_id, preset, max_new_tokens, result_path)


if __name__ == "__main__":
    sys.exit(main())