File size: 24,597 Bytes
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a770afa
 
 
 
 
783a079
 
 
 
 
 
 
 
 
 
a770afa
783a079
 
 
a770afa
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a770afa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a770afa
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a770afa
 
 
 
 
 
 
 
783a079
 
 
 
 
a770afa
783a079
 
 
 
 
 
 
 
 
 
a770afa
 
783a079
 
 
 
 
 
 
 
 
 
 
a770afa
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
a770afa
783a079
 
 
 
 
a770afa
 
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a770afa
783a079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
"""Script 3: refractory response gate for the Laola analogue.

Standalone point:
- Builds on Script 1/2, but adds the first local state dynamic.
- A node does not respond automatically when a signal arrives.
- It responds only if its local live state has recovered from previous activation.
- Therefore repeated waves can pass, weaken, or fail.

Didactic status:
- Still not a CNNA Schur/DtN model.
- First transition from kinematic schedules to stateful response propagation.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal, Tuple

import gradio as gr
import matplotlib.pyplot as plt
import numpy as np

try:
    from modules.common import (
        BASE,
        activity_from_schedule,
        count_visible_packets,
        exact_regular_schedule,
        launch_offsets,
        make_heatmap,
        make_snapshot_comparison,
        make_timing_error_plot,
        time_grid,
        timing_error,
        transport_diffusion_residuals,
        response_profile,
    )
except ModuleNotFoundError:  # direct execution as python modules/script_XX.py
    from common import (
        BASE,
        activity_from_schedule,
        count_visible_packets,
        exact_regular_schedule,
        launch_offsets,
        make_heatmap,
        make_snapshot_comparison,
        make_timing_error_plot,
        time_grid,
        timing_error,
        transport_diffusion_residuals,
        response_profile,
    )


RefractoryMode = Literal["binary", "partial_recovery"]
FailurePolicy = Literal["block", "attenuate"]

# Script 3 intentionally uses a stress default so the new refractory gate is visible.
# The Script-1/2 baseline remains available by setting T_launch back to BASE.launch_interval.
SCRIPT3_DEMO_LAUNCH_INTERVAL = 2.00
SCRIPT3_DEMO_SNAPSHOT_TIME = 18.00


@dataclass(frozen=True)
class RefractoryParams:
    n_people: int = BASE.n_people
    tau_mean: float = BASE.tau
    tau_jitter: float = 0.0
    activation_time: float = BASE.activation_time
    refractory_time: float = BASE.refractory_time
    seat_spacing: float = BASE.seat_spacing
    launch_count: int = BASE.launch_count
    launch_interval: float = SCRIPT3_DEMO_LAUNCH_INTERVAL
    damping_per_node: float = 0.0
    dt: float = BASE.dt
    t_max: float = BASE.t_max
    snapshot_time: float = SCRIPT3_DEMO_SNAPSHOT_TIME
    refractory_mode: RefractoryMode = "binary"
    failure_policy: FailurePolicy = "block"
    wrap_ring: bool = False
    seed: int = 7


def edge_delay_schedule(params: RefractoryParams) -> Tuple[np.ndarray, np.ndarray]:
    """Return edge delays τ_i and raw jitter ξ_i.

    Edge i is the delay from node i to node i+1 in the open-chain case.
    For ring mode the last edge N-1 goes from node N-1 back to node 0.

    Zero-jitter is constructed exactly as a constant array to avoid turning
    binary floating point drift into a false model diagnostic.
    """
    n = max(0, int(params.n_people))
    edge_count = n if bool(params.wrap_ring) else max(n - 1, 0)
    tau = float(params.tau_mean)
    sigma = max(float(params.tau_jitter), 0.0)

    if edge_count == 0:
        return np.zeros(0, dtype=float), np.zeros(0, dtype=float)

    if sigma == 0.0:
        return np.full(edge_count, tau, dtype=float), np.zeros(edge_count, dtype=float)

    rng = np.random.default_rng(int(params.seed))
    raw_jitter = rng.normal(0.0, sigma, size=edge_count)
    edge_delays = np.clip(tau + raw_jitter, 0.02, None)
    return edge_delays, raw_jitter


def _recovery_factor(delta_since_last: float, params: RefractoryParams) -> float:
    """Local live-state gate for a new response attempt.

    delta_since_last measures the time between the current arrival and the node's
    previous activation. The node is still active for T_act, then refractory for
    T_ref. In binary mode it accepts only after the full recovery gap. In partial
    mode it recovers linearly after the active period.
    """
    if not np.isfinite(delta_since_last):
        return 1.0

    t_act = max(float(params.activation_time), 0.0)
    t_ref = max(float(params.refractory_time), 1e-12)
    full_gap = t_act + t_ref

    if str(params.refractory_mode) == "binary":
        return 1.0 if delta_since_last >= full_gap else 0.0

    # partial_recovery: active phase gives zero response; refractory phase recovers linearly.
    return float(np.clip((delta_since_last - t_act) / t_ref, 0.0, 1.0))


def propagate_events(
    params: RefractoryParams,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Propagate launch signals through local refractory gates.

    Returns
    -------
    event_times:
        Shape (launch_count, N). Accepted activation time or NaN.
    response_strength:
        Shape (launch_count, N). Local response amplitude. 1 = full response,
        0 = blocked or unreached.
    status:
        Shape (launch_count, N). 1 accepted, 0 blocked at this node, -1 unreached.
    edge_delays:
        Edge delay schedule.
    raw_jitter:
        Raw ξ values used to build edge_delays.
    """
    n = max(0, int(params.n_people))
    launches = launch_offsets(params.launch_count, params.launch_interval)
    m_count = len(launches)

    event_times = np.full((m_count, n), np.nan, dtype=float)
    response_strength = np.zeros((m_count, n), dtype=float)
    status = np.full((m_count, n), -1.0, dtype=float)
    last_activation = np.full(n, -np.inf, dtype=float)

    edge_delays, raw_jitter = edge_delay_schedule(params)

    for m, launch_time in enumerate(launches):
        arrival_time = float(launch_time)
        node = 0
        visited_edges = 0
        max_edges = n if bool(params.wrap_ring) else max(n - 1, 0)

        while 0 <= node < n:
            delta = arrival_time - last_activation[node]
            recovery = _recovery_factor(delta, params)

            accepted = False
            local_strength = 0.0
            if str(params.failure_policy) == "block":
                if recovery > 0.0:
                    accepted = True
                    local_strength = 1.0 if str(params.refractory_mode) == "binary" else recovery
            else:  # attenuate
                accepted = True
                local_strength = recovery

            if not accepted or local_strength <= 0.0:
                status[m, node] = 0.0
                break

            status[m, node] = 1.0
            event_times[m, node] = arrival_time
            response_strength[m, node] = local_strength
            last_activation[node] = arrival_time

            if not bool(params.wrap_ring):
                if node >= n - 1:
                    break
                edge_idx = node
                arrival_time = arrival_time + float(edge_delays[edge_idx])
                node = node + 1
            else:
                if visited_edges >= max_edges - 1:
                    break
                edge_idx = node
                arrival_time = arrival_time + float(edge_delays[edge_idx])
                node = (node + 1) % n
                visited_edges += 1

    return event_times, response_strength, status, edge_delays, raw_jitter


def activity_from_events(
    times: np.ndarray,
    event_times: np.ndarray,
    response_strength: np.ndarray,
    activation_time: float,
    damping_per_node: float,
) -> np.ndarray:
    """Build H[t, i] from accepted stateful events."""
    if event_times.ndim != 2:
        raise ValueError("event_times must have shape (launch_count, n_people)")

    launch_count, n = event_times.shape
    heights = np.zeros((len(times), n), dtype=float)
    node_damping = np.exp(-max(float(damping_per_node), 0.0) * np.arange(n, dtype=float))

    for m in range(launch_count):
        for i in range(n):
            t0 = event_times[m, i]
            amp = float(response_strength[m, i]) * float(node_damping[i])
            if not np.isfinite(t0) or amp <= 0.0:
                continue
            local = times - float(t0)
            heights[:, i] = np.maximum(heights[:, i], amp * response_profile(local, activation_time))
    return heights


def first_activation_times(event_times: np.ndarray) -> np.ndarray:
    """First accepted activation per node; NaN if no accepted event reached it."""
    if event_times.size == 0:
        return np.zeros(0, dtype=float)
    with np.errstate(all="ignore"):
        first = np.nanmin(event_times, axis=0)
    first[~np.isfinite(first)] = np.nan
    return first


def first_wave_reference_from_edges(n: int, edge_delays: np.ndarray, tau_mean: float) -> np.ndarray:
    """Reference schedule for the first launch using the same edge delays.

    With zero jitter this is exactly i·τ. With jitter it matches Script 2's
    cumulative edge-delay schedule, so refractory failures are not confused with
    timing noise.
    """
    if n <= 0:
        return np.zeros(0, dtype=float)
    if edge_delays.size == 0:
        return np.zeros(n, dtype=float)
    if np.all(edge_delays == float(tau_mean)):
        return exact_regular_schedule(n, float(tau_mean))
    out = np.zeros(n, dtype=float)
    if n > 1:
        out[1:] = np.cumsum(edge_delays[: n - 1])
    return out


def make_response_deficit_plot(positions: np.ndarray, status: np.ndarray):
    """Plot missing accepted responses relative to the ideal Script-1 launch count.

    Script 1 assumes every launch reaches every node. Script 3 may block a launch
    at a gate; downstream nodes are then not reached. This plot is therefore the
    correct default-visible deviation for the refractory module. Pure timing error
    of accepted events can remain zero while the response count already differs.
    """
    fig, ax = plt.subplots(figsize=(10, 3.6))
    if status.size == 0:
        ax.set_title("Script 3: fehlende Responses gegen ideale Referenz — keine Daten")
        return fig
    accepted_counts = np.sum(status == 1.0, axis=0)
    launch_count = status.shape[0]
    deficit = launch_count - accepted_counts
    blocked_counts = np.sum(status == 0.0, axis=0)
    unreached_counts = np.sum(status == -1.0, axis=0)
    ax.plot(positions, deficit, marker="o", linewidth=1.5, markersize=3, label="fehlende akzeptierte Responses")
    ax.plot(positions, blocked_counts, linestyle="--", linewidth=1.2, label="direkt blockiert")
    ax.plot(positions, unreached_counts, linestyle=":", linewidth=1.2, label="nicht erreicht")
    ax.set_xlabel("Knoten / Sitzposition xᵢ")
    ax.set_ylabel("Anzahl Launches")
    ax.set_title("Script 3: Response-Defizit relativ zum idealen Script-1-Wellenzug")
    ax.set_ylim(-0.05, max(1.0, float(launch_count)) + 0.2)
    ax.grid(True, alpha=0.3)
    ax.legend(loc="best")
    fig.tight_layout()
    return fig

def make_refractory_map(positions: np.ndarray, status: np.ndarray):
    """Plot launch × node acceptance map.

    Values:
    -1 = signal did not reach this node because an earlier gate stopped the wave.
     0 = signal reached this node but was blocked by local refractory state.
     1 = node accepted and responded.
    """
    fig, ax = plt.subplots(figsize=(10, 3.8))
    if status.size == 0:
        ax.set_title("Refraktär-/Failure-Map: keine Daten")
        return fig
    extent = [positions[0], positions[-1], -0.5, status.shape[0] - 0.5]
    im = ax.imshow(status, aspect="auto", origin="lower", extent=extent, vmin=-1.0, vmax=1.0)
    cbar = fig.colorbar(im, ax=ax)
    cbar.set_label("-1 nicht erreicht · 0 blockiert · 1 akzeptiert")
    ax.set_xlabel("Knoten / Sitzposition xᵢ")
    ax.set_ylabel("Startimpuls m")
    ax.set_title("Script 3: Refraktär-/Failure-Map pro Launch und Knoten")
    fig.tight_layout()
    return fig


def simulate(
    params: RefractoryParams,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    n = int(params.n_people)
    times = time_grid(params.t_max, params.dt)
    positions = params.seat_spacing * np.arange(n, dtype=float)
    launches = launch_offsets(params.launch_count, params.launch_interval)

    event_times, strengths, status, edge_delays, raw_jitter = propagate_events(params)
    heights = activity_from_events(times, event_times, strengths, params.activation_time, params.damping_per_node)

    reference_activation = exact_regular_schedule(n, params.tau_mean)
    reference = activity_from_schedule(times, reference_activation, launches, params.activation_time)
    return times, positions, launches, event_times, strengths, status, edge_delays, raw_jitter, heights, reference


def run_model(
    n_people: int,
    tau_mean: float,
    tau_jitter: float,
    activation_time: float,
    refractory_time: float,
    seat_spacing: float,
    launch_count: int,
    launch_interval: float,
    damping_per_node: float,
    refractory_mode: str,
    failure_policy: str,
    wrap_ring: bool,
    dt: float,
    t_max: float,
    snapshot_time: float,
    seed: int,
):
    params = RefractoryParams(
        n_people=int(n_people),
        tau_mean=float(tau_mean),
        tau_jitter=float(tau_jitter),
        activation_time=float(activation_time),
        refractory_time=float(refractory_time),
        seat_spacing=float(seat_spacing),
        launch_count=int(launch_count),
        launch_interval=float(launch_interval),
        damping_per_node=float(damping_per_node),
        refractory_mode=str(refractory_mode),  # type: ignore[arg-type]
        failure_policy=str(failure_policy),  # type: ignore[arg-type]
        wrap_ring=bool(wrap_ring),
        dt=float(dt),
        t_max=float(t_max),
        snapshot_time=float(snapshot_time),
        seed=int(seed),
    )

    (
        times,
        positions,
        launches,
        event_times,
        strengths,
        status,
        edge_delays,
        raw_jitter,
        heights,
        reference,
    ) = simulate(params)

    first_actual = first_activation_times(event_times)
    edge_reference = first_wave_reference_from_edges(params.n_people, edge_delays, params.tau_mean)
    errors = timing_error(first_actual, edge_reference)
    errors[~np.isfinite(first_actual)] = np.nan

    tau_emp = float(np.mean(edge_delays)) if edge_delays.size else float(params.tau_mean)
    sigma_emp = 0.0 if float(params.tau_jitter) == 0.0 else float(np.std(edge_delays))
    c_emp = params.seat_spacing / max(tau_emp, 1e-12)
    sigma2 = float(np.var(edge_delays)) if edge_delays.size else 0.0
    d_eff = 0.0 if sigma2 <= 1e-30 else (params.seat_spacing**2 * sigma2) / (2.0 * max(tau_emp, 1e-12) ** 3)
    r_transport, r_diffusion = transport_diffusion_residuals(times, positions, heights, c_emp, d_eff)

    no_extra_irregularity = (float(params.tau_jitter) == 0.0) and (float(params.damping_per_node) == 0.0)
    full_recovery_gap = max(float(params.activation_time), 0.0) + max(float(params.refractory_time), 0.0)
    gate_should_matter = float(params.launch_interval) < full_recovery_gap

    heat_title = (
        "Script 3: Refraktär-Gate aktiv — Wellen können lokal scheitern"
        if gate_should_matter
        else "Script 3: Refraktär-Gate aktiv — Default lässt vollständige Erholung zu"
    )
    heatmap = make_heatmap(times, positions, heights, heat_title)
    snapshot = make_snapshot_comparison(
        times,
        positions,
        heights,
        reference,
        params.snapshot_time,
        "Script 3 simuliert",
    )
    schedule = make_timing_error_plot(
        positions,
        errors,
        "Script 3: erster akzeptierter Launch — Timing-Fehler relativ zur edge-basierten Referenz",
    )
    response_deficit = make_response_deficit_plot(positions, status)
    refractory_map = make_refractory_map(positions, status)

    snapshot_idx = int(np.argmin(np.abs(times - params.snapshot_time)))
    visible_packets = count_visible_packets(heights[snapshot_idx, :])
    active_nodes = int(np.sum(heights[snapshot_idx, :] > 0.05))
    accepted = int(np.sum(status == 1.0))
    blocked = int(np.sum(status == 0.0))
    unreached = int(np.sum(status == -1.0))
    total_attempt_slots = int(status.size)
    pass_rate = accepted / max(total_attempt_slots, 1)
    full_launches = int(np.sum(np.all(status == 1.0, axis=1))) if status.size else 0
    failed_launches = int(params.launch_count) - full_launches
    first_block_per_launch = []
    for m in range(status.shape[0]):
        blocked_nodes = np.where(status[m, :] == 0.0)[0]
        first_block_per_launch.append(int(blocked_nodes[0]) if blocked_nodes.size else None)
    first_blocks_text = ", ".join("–" if x is None else str(x) for x in first_block_per_launch)

    max_abs_error = float(np.nanmax(np.abs(errors))) if np.any(np.isfinite(errors)) else float("nan")
    max_abs_delta_snapshot = float(np.max(np.abs(heights[snapshot_idx, :] - reference[snapshot_idx, :]))) if heights.size else 0.0
    launch_spacing_nodes = params.launch_interval / max(tau_emp, 1e-12)
    refractory_warning = "Ja — spätere Launches können in noch nicht erholte Knoten laufen." if gate_should_matter else "Nein — Default-Abstand lässt volle Erholung zu."
    zero_note = "Ja — bis auf das neue Refraktär-Gate." if no_extra_irregularity else "Nein — Timing-Streuung oder Dämpfung ist aktiv."

    interpretation = f"""
## Script 3 — Interpretation

**Neue lokale Regel: Refraktär-Gate / endliche Responsefähigkeit**

Script 1 und 2 zeichnen Aktivierungen im Wesentlichen aus einem Schedule. Script 3 macht daraus
zum ersten Mal eine lokale Zustandsdynamik:

```text
ansprechbar → aktiv → refraktär → ansprechbar

Signal kommt bei Knoten i an.
Knoten i antwortet nur, wenn seit seiner letzten Aktivierung mindestens
T_act + T_ref vergangen ist.
```

Minimal-binär:

```text
response_i = 1, falls Δt_i ≥ T_act + T_ref
response_i = 0, sonst
```

Bei `partial_recovery` wird die Erholung linear angenähert:

```text
response_i = clamp((Δt_i - T_act) / T_ref, 0, 1)
```

| Größe | Wert |
|---|---:|
| Knotenanzahl N | {params.n_people} |
| Startimpulse launch_count | {params.launch_count} |
| Startabstand T_launch | {params.launch_interval:.3f} s |
| Erholungsschwelle T_act + T_ref | {full_recovery_gap:.3f} s |
| Startabstand in Knoten T_launch/mean(τ_i) | {launch_spacing_nodes:.2f} Knoten |
| Refraktärwarnung T_launch < T_act+T_ref | {refractory_warning} |
| Refraktärmodus | {params.refractory_mode} |
| Failure-Policy | {params.failure_policy} |
| Ringmodus | {params.wrap_ring} |
| sichtbare Pakete im Snapshot | {visible_packets} |
| aktive Knoten im Snapshot | {active_nodes} |
| akzeptierte Knoten-Launch-Ereignisse | {accepted} |
| blockierte Ereignisse | {blocked} |
| nicht erreichte Ereignisse | {unreached} |
| Pass-Rate akzeptiert / Slots | {pass_rate:.3f} |
| vollständig durchgelaufene Launches | {full_launches} |
| gescheiterte Launches | {failed_launches} |
| erster Block pro Launch | {first_blocks_text} |
| mean(τ_i) | {tau_emp:.4f} s |
| std(τ_i) | {sigma_emp:.4e} s |
| Dämpfung pro Knoten α | {params.damping_per_node:.5f} |
| max. Timing-Fehler des ersten akzeptierten Launchs | {max_abs_error:.4e} s |
| max. Snapshot-Abweichung gegen Script 1 | {max_abs_delta_snapshot:.4e} |
| heuristisches D_eff aus Timing-Streuung | {d_eff:.6e} |
| normierter Transport-Residualindikator | {r_transport:.4f} |
| normierter Transport-Diffusions-Residualindikator | {r_diffusion:.4f} |
| keine Script-2-Irregularität aktiv? | {zero_note} |

**Lesart:** Eine Welle ist jetzt nicht mehr garantiert. Die Propagation hängt vom lokalen
Live-Zustand ab. Damit wird aus einem vorgegebenen Wellenzug eine Response-Kette mit Gedächtnis.
Das ist der erste relevante Schritt in Richtung CNNA-Sprache: lokale Antwortfähigkeit, Relaxation,
Record/live-Unterscheidung und Scheitern einer Response-Propagation.

**Wichtiger Default-Hinweis:** Script 3 verwendet jetzt als Demonstrations-Default `T_launch = 2.0 s`.
Da `T_act + T_ref = 3.2 s`, laufen einige spätere Launches in noch nicht erholte Knoten.
Die gemeinsame Script-1/2-Baseline erhältst du durch `T_launch = 4.0 s`; dann bleibt das Gate korrekt,
aber im Plot weitgehend unsichtbar.

**Warum der Timing-Plot trotzdem null sein kann:** Bei `std(ξ)=0` verschiebt das Refraktär-Gate akzeptierte
Ereignisse nicht zeitlich; es akzeptiert oder blockiert sie. Die relevante Abweichung ist daher nicht
Timing-Drift, sondern Response-Defizit und Failure-Map.

**Unsicherheit / Modellgrenze:** Das ist noch kein Schur-/DtN-Netzwerk. Die Refraktärregel ist eine
kontrollierte lokale Automat-Regel. Im späteren CNNA-Modul müsste die Antwortfähigkeit aus der
live-Randantwort beziehungsweise einer lokalen Schur-/DtN-Response abgeleitet werden.
"""
    return heatmap, snapshot, schedule, response_deficit, refractory_map, interpretation


def build_tab() -> None:
    gr.Markdown(
        r"""
## Script 3 — Refraktär-Gate: lokale Response kann scheitern

Dieses Modul baut auf Script 1/2 auf, fügt aber ein neues wesentliches Detail hinzu:
Ein Knoten reagiert nicht automatisch. Er reagiert nur, wenn seine lokale live-Response wieder frei ist.

Der Script-3-Default ist absichtlich ein Refraktär-Stresstest (`T_launch = 2.0 s`), damit das neue Gate sofort sichtbar wird.
Die gemeinsame Script-1/2-Baseline ist weiterhin verfügbar: `T_launch = 4.0 s` setzen.
"""
    )
    with gr.Row():
        with gr.Column(scale=1):
            n_people = gr.Slider(20, 240, value=BASE.n_people, step=1, label="N Menschen / Knoten")
            tau_mean = gr.Slider(0.05, 0.80, value=BASE.tau, step=0.01, label="mittlere τ [s]")
            tau_jitter = gr.Slider(0.00, 0.20, value=0.0, step=0.005, label="std(ξ) Timing-Streuung [s]")
            activation_time = gr.Slider(0.20, 4.00, value=BASE.activation_time, step=0.05, label="T_act Aktivitätsdauer [s]")
            refractory_time = gr.Slider(0.20, 8.00, value=BASE.refractory_time, step=0.05, label="T_ref Refraktärzeit [s]")
            seat_spacing = gr.Slider(0.20, 3.00, value=BASE.seat_spacing, step=0.05, label="a Knotenabstand")
            launch_count = gr.Slider(1, 12, value=BASE.launch_count, step=1, label="launch_count Startimpulse")
            launch_interval = gr.Slider(0.50, 12.00, value=SCRIPT3_DEMO_LAUNCH_INTERVAL, step=0.10, label="T_launch Abstand der Startimpulse [s]")
            damping_per_node = gr.Slider(0.00, 0.04, value=0.0, step=0.001, label="α Dämpfung pro Knoten")
            refractory_mode = gr.Radio(
                choices=["binary", "partial_recovery"],
                value="binary",
                label="Refraktärmodus",
            )
            failure_policy = gr.Radio(
                choices=["block", "attenuate"],
                value="block",
                label="Failure-Policy",
            )
            wrap_ring = gr.Checkbox(value=False, label="Ringmodus statt offener Reihe")
            dt = gr.Slider(0.01, 0.10, value=BASE.dt, step=0.01, label="dt Zeitauflösung [s]")
            t_max = gr.Slider(4.00, 80.00, value=BASE.t_max, step=0.50, label="t_max [s]")
            snapshot_time = gr.Slider(0.00, 80.00, value=SCRIPT3_DEMO_SNAPSHOT_TIME, step=0.10, label="Momentaufnahme t [s]")
            seed = gr.Number(value=7, precision=0, label="Seed für reproduzierbare ξᵢ")
            run = gr.Button("Simulation ausführen", variant="primary")
        with gr.Column(scale=2):
            heatmap = gr.Plot(label="Raum-Zeit-Aktivität")
            snapshot = gr.Plot(label="Momentaufnahme gegen ideale Referenz")
            schedule = gr.Plot(label="Timing-Fehler akzeptierter erster Durchlauf")
            response_deficit = gr.Plot(label="Response-Defizit gegen ideale Referenz")
            refractory_map = gr.Plot(label="Refraktär-/Failure-Map")
            interpretation = gr.Markdown()

    inputs = [
        n_people,
        tau_mean,
        tau_jitter,
        activation_time,
        refractory_time,
        seat_spacing,
        launch_count,
        launch_interval,
        damping_per_node,
        refractory_mode,
        failure_policy,
        wrap_ring,
        dt,
        t_max,
        snapshot_time,
        seed,
    ]
    outputs = [heatmap, snapshot, schedule, response_deficit, refractory_map, interpretation]
    run.click(run_model, inputs=inputs, outputs=outputs)


if __name__ == "__main__":
    params = RefractoryParams()
    t, x, launches, events, strengths, status, delays, jitter, h, ref = simulate(params)
    print(
        t.shape,
        x.shape,
        launches.shape,
        events.shape,
        strengths.shape,
        status.shape,
        delays.shape,
        jitter.shape,
        h.shape,
        ref.shape,
    )