# WO-3 (P-1 θ 자동튜닝, passive 교차상관) — 완전코드 작업지시서

> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙**한다.
> **선행 완료 전제(필수)**: §0 + WO-1 + **WO-2 머지 완료**. 즉 `ColumnConfig.SteamOpTag/ThetaAutoTune/SensitiveTrayTag`,
> `StreamAdvisory.ThetaSuggestUpSec/DnSec/Conf`(§0), `BuildTemps`/`ColumnState.PRefSeeded/PRefValue`(WO-2),
> `AdvisoryResult.Temps`·`PvSnapshot.Temps`는 **이미 존재**한다(다시 만들지 말 것). WO-2가 안 됐으면 WO-2 먼저.
> **불변식**: advisory — 제어 레지스터 쓰기 0건. **config의 θ는 절대 변경하지 않는다.** 화면에 "제안"만 표시(운전원이 수동 반영).

## 목적
정상 운전 중 **자연 외란**으로 피드→온도(PCT) 전달지연 θ를 **passive 교차상관**으로 식별해 commanded 스트림에 **제안**한다.
spec §13.4: `θ = argmax_τ ρ(ΔF(t), ΔPCT(t+τ))`, **스팀 OP(TICA.OP)를 부분상관으로 제거**해 폐루프 오염 회피(함정 ④).
외란 부족·신뢰 낮으면 **제안 억제(null)**. seed θ가 전부 placeholder인 문제(PhaseI §5.8)를 데이터로 보정.

> **현실 경고(spec §13.2·§13.7)**: 단일점 생온도 SNR 낮음 → θ는 **신뢰도 등급 붙은 추정치**. 데모 온도는 인위생성이라
> 실플랜트 전 가동 스위치 `ThetaAutoTune`는 **기본 false**. 본 WO는 블록·배선·테스트까지 턴키로 두되 옵트인.

## 변경 파일 (총 6개)
1. `src/Infrastructure/Control/CrossCorrLagEstimator.cs` — **신규** 블록
2. `src/Core/Application/Feedforward/FeedforwardModels.cs` — `PvSnapshot.SteamOp` init 프로퍼티
3. `src/Infrastructure/Control/FeedforwardSupervisor.cs` — 스팀 OP 읽기(.op는 .pv 아님)
4. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` 필드 + `ApplyThetaSuggestion` + Tick 배선
5. `src/Web/wwwroot/js/ff.js` — θ 제안 표시 (Controller는 §0에서 이미 `thetaSuggest*` 노출 — **변경 없음**)
6. `src/Web/wwwroot/css/ff.css` — θ 행 스타일
7. `tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs` — **신규** 테스트

---

## STEP 1 — 신규 파일 `CrossCorrLagEstimator.cs`

**신규 파일**: `src/Infrastructure/Control/CrossCorrLagEstimator.cs`

```csharp
namespace ExperionCrawler.Infrastructure.Control;

/// <summary>
/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ.
/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4).
/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요).
/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환).
/// </summary>
public sealed class CrossCorrLagEstimator
{
    private readonly int    _maxLag;          // 탐색할 최대 지연(샘플)
    private readonly int    _hist;            // 보존 이력(샘플)
    private readonly double _minStd;          // 외란 최소 표준편차(미달 시 억제)
    private readonly int    _recomputeEvery;  // argmax 재계산 주기(호출 수)
    private readonly Queue<double> _f = new();
    private readonly Queue<double> _r = new();
    private readonly Queue<double> _s = new();
    private int _sinceCompute;
    private (double thetaUpSec, double thetaDnSec, double conf)? _last;

    public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd, int recomputeEvery = 30)
    {
        _maxLag         = Math.Max(1, maxLagSamples);
        _hist           = Math.Max(_maxLag * 2, historySamples);
        _minStd         = minSignalStd;
        _recomputeEvery = Math.Max(1, recomputeEvery);
    }

    public (double thetaUpSec, double thetaDnSec, double conf)? Push(
        double dFeed, double dResponse, double dSteam, double tsSec)
    {
        _f.Enqueue(dFeed); _r.Enqueue(dResponse); _s.Enqueue(dSteam);
        while (_f.Count > _hist) { _f.Dequeue(); _r.Dequeue(); _s.Dequeue(); }

        if (_f.Count < _maxLag * 2) return _last;          // 외란 누적 부족 → 직전 결과(초기 null)

        _sinceCompute++;
        if (_last is not null && _sinceCompute < _recomputeEvery) return _last;  // 캐시
        _sinceCompute = 0;

        var f = _f.ToArray(); var r = _r.ToArray(); var s = _s.ToArray();
        int n = f.Length;

        if (Std(f) < _minStd) { _last = null; return null; } // 피드 외란 없음 → 억제

        // 부분상관: r에서 s의 동시점 선형성분 제거 (잔차)
        double beta = Cov(r, s) / Math.Max(1e-12, Var(s));
        var resid = new double[n];
        for (int i = 0; i < n; i++) resid[i] = r[i] - beta * s[i];

        // 방향별 θ (상승/하강 비대칭). 표본 부족 시 NaN.
        var (tu, cu) = BestLag(f, resid, n, x => x > 0, tsSec);
        var (td, cd) = BestLag(f, resid, n, x => x < 0, tsSec);

        bool haveUp = !double.IsNaN(tu), haveDn = !double.IsNaN(td);
        if (!haveUp && !haveDn) { _last = null; return null; }
        if (!haveUp) { tu = td; cu = cd; }
        if (!haveDn) { td = tu; cd = cu; }

        double conf = Math.Min(cu, cd);
        if (conf < 0.3) { _last = null; return null; }       // 신뢰 부족 → 억제

        _last = (tu, td, conf);
        return _last;
    }

    /// <summary>mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0).</summary>
    private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func<double, bool> mask, double tsSec)
    {
        int masked = 0;
        for (int i = 0; i < n; i++) if (mask(f[i])) masked++;
        if (masked < _maxLag) return (double.NaN, 0.0);

        double bestRho = double.NegativeInfinity; int bestTau = 0;
        for (int tau = 0; tau <= _maxLag; tau++)
        {
            double sfr = 0, sff = 0, srr = 0; int m = 0;
            for (int i = 0; i + tau < n; i++)
            {
                if (!mask(f[i])) continue;
                double a = f[i], b = resid[i + tau];
                sfr += a * b; sff += a * a; srr += b * b; m++;
            }
            if (m < 3 || sff <= 0 || srr <= 0) continue;
            double rho = sfr / Math.Sqrt(sff * srr);          // Δ신호라 비중심 상관
            if (rho > bestRho) { bestRho = rho; bestTau = tau; }
        }
        if (double.IsNegativeInfinity(bestRho)) return (double.NaN, 0.0);
        return (bestTau * tsSec, Math.Max(0.0, bestRho));
    }

    private static double Mean(double[] a) { double s = 0; foreach (var x in a) s += x; return s / a.Length; }
    private static double Var(double[] a)  { double m = Mean(a), s = 0; foreach (var x in a) s += (x - m) * (x - m); return s / a.Length; }
    private static double Std(double[] a)  => Math.Sqrt(Var(a));
    private static double Cov(double[] a, double[] b)
    { double ma = Mean(a), mb = Mean(b), s = 0; for (int i = 0; i < a.Length; i++) s += (a[i] - ma) * (b[i] - mb); return s / a.Length; }
}
```

---

## STEP 2 — `FeedforwardModels.cs` : `PvSnapshot.SteamOp` 추가

**파일**: `src/Core/Application/Feedforward/FeedforwardModels.cs`

**찾기** (WO-2가 추가한 `PvSnapshot`의 Temps 프로퍼티):
```csharp
    IReadOnlyDictionary<string, TagSample> Streams)
{
    public IReadOnlyList<TagSample>? Temps { get; init; }
}
```

**바꾸기**:
```csharp
    IReadOnlyDictionary<string, TagSample> Streams)
{
    public IReadOnlyList<TagSample>? Temps { get; init; }
    public TagSample? SteamOp { get; init; }   // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
}
```

---

## STEP 3 — `FeedforwardSupervisor.cs` : 스팀 OP 읽기

> ⚠️ **`SteamOpTag`은 `.OP`(컨트롤러 출력)이지 `.pv`가 아니다.** `Sample()`/`PvTag()`는 `.pv`를 강제 부착하므로
> 스팀엔 쓰면 안 된다. 아래처럼 **태그를 그대로(소문자) 읽는 SampleExact**를 추가한다.

### 3.1 읽을 태그 목록에 SteamOpTag 추가

**찾기** (WO-2가 추가한 TempTags 줄):
```csharp
        tags.AddRange(cfg.TempTags.Select(PvTag));   // WO-2 온도 프로파일
```

**바꾸기**:
```csharp
        tags.AddRange(cfg.TempTags.Select(PvTag));   // WO-2 온도 프로파일
        if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant());  // WO-3 스팀 OP(.op 그대로)
```

### 3.2 SampleExact 헬퍼 추가 (Sample 바로 뒤)

**찾기** (기존 `Sample` 로컬함수의 닫는 부분):
```csharp
            return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
        }

        var feed = Sample(cfg.FeedTag);
```

**바꾸기**:
```csharp
            return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
        }

        // WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음
        TagSample SampleExact(string rawTag)
        {
            var tag = rawTag.ToLowerInvariant();
            if (rows.TryGetValue(tag, out var r)
                && double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
            {
                bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
                return new TagSample(tag, v, Good: fresh, r.Timestamp);
            }
            return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
        }

        var feed = Sample(cfg.FeedTag);
```

### 3.3 PvSnapshot에 SteamOp 채우기

> 전제: WO-2에서 이 return은 이미 `{ Temps = temps }` 형태다.

**찾기**:
```csharp
        var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
        return new PvSnapshot(feed, press, levels, streams) { Temps = temps };
```

**바꾸기**:
```csharp
        var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
        var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
        return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam };
```

---

## STEP 4 — `FeedforwardEngine.cs` : 상태필드 + θ 제안 배선

**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`

### 4.1 `ColumnState`에 θ 추정 상태 추가

> 전제: WO-2에서 `PRefSeeded`/`PRefValue`가 이미 추가됨.

**찾기**:
```csharp
    public bool          PRefSeeded { get; set; }
    public double        PRefValue  { get; set; } = double.NaN;
    public Dictionary<string, StreamState> Streams { get; } = new();
```

**바꾸기**:
```csharp
    public bool          PRefSeeded { get; set; }
    public double        PRefValue  { get; set; } = double.NaN;
    // WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
    public CrossCorrLagEstimator? ThetaEst { get; set; }
    public double        PrevFeedFiltered { get; set; } = double.NaN;
    public double        PrevRespPct      { get; set; } = double.NaN;
    public double        PrevSteamOp      { get; set; } = double.NaN;
    public Dictionary<string, StreamState> Streams { get; } = new();
```

### 4.2 Tick 배선 — return 직전에 θ 제안 적용

> 전제: WO-2에서 return이 `var temps = BuildTemps(...)` + `{ Temps = temps }` 형태다.

**찾기**:
```csharp
        var temps = BuildTemps(cfg, pv, st);   // WO-2 PCT 모니터
        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
            transient, treason, ff, outs, vloss, yield, mbState)
            { Temps = temps };
```

**바꾸기**:
```csharp
        var temps = BuildTemps(cfg, pv, st);   // WO-2 PCT 모니터
        ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs);   // WO-3 θ 제안(advisory)
        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
            transient, treason, ff, outs, vloss, yield, mbState)
            { Temps = temps };
```

### 4.3 `ApplyThetaSuggestion` 메서드 추가 (BuildTemps 바로 뒤)

> 전제: WO-2가 추가한 `BuildTemps` 메서드는 `return list;` + `}` 로 끝난다.

**찾기** (BuildTemps의 마지막):
```csharp
            list.Add(new TempPoint(t.Tag, raw, pct, good));
        }
        return list;
    }
```

**바꾸기**:
```csharp
            list.Add(new TempPoint(t.Tag, raw, pct, good));
        }
        return list;
    }

    // ── WO-3 P-1: passive θ 식별 → commanded 스트림에 "제안"만(config θ 무변경) ──────
    private static void ApplyThetaSuggestion(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts,
        IReadOnlyList<TempPoint>? temps, ref List<StreamAdvisory> outs)
    {
        if (!cfg.ThetaAutoTune) return;                 // 옵트인(기본 off)
        if (temps is null || temps.Count == 0) return;

        // 응답 신호 = 민감트레이 PCT(없으면 첫 온도 PCT)
        double respPct = double.NaN;
        if (cfg.SensitiveTrayTag is not null)
        {
            var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
            foreach (var tp in temps) if (tp.Tag == key && tp.Good) { respPct = tp.Pct; break; }
        }
        if (double.IsNaN(respPct) && temps[0].Good) respPct = temps[0].Pct;
        if (double.IsNaN(respPct)) return;

        double feedNow  = st.FeedFilter.Value;
        double steamNow = pv.SteamOp is { Good: true } so && Num.IsFinite(so.Value) ? so.Value : 0.0;

        // 1차차분(Δ=사전백색화). 최초 호출은 prev가 NaN이라 Δ=0(시드)
        double dF = Num.IsFinite(st.PrevFeedFiltered) ? feedNow  - st.PrevFeedFiltered : 0.0;
        double dR = Num.IsFinite(st.PrevRespPct)      ? respPct  - st.PrevRespPct      : 0.0;
        double dS = Num.IsFinite(st.PrevSteamOp)      ? steamNow - st.PrevSteamOp      : 0.0;
        st.PrevFeedFiltered = feedNow; st.PrevRespPct = respPct; st.PrevSteamOp = steamNow;

        st.ThetaEst ??= new CrossCorrLagEstimator(
            maxLagSamples:  Math.Max(1, (int)Math.Round(1200.0 / Math.Max(1e-6, ts))),  // ~20분 지연 탐색
            historySamples: Math.Max(1, (int)Math.Round(3600.0 / Math.Max(1e-6, ts))),  // ~1시간 이력
            minSignalStd:   1e-9);

        var est = st.ThetaEst.Push(dF, dR, dS, ts);
        if (est is null) return;
        var (tu, td, conf) = est.Value;

        outs = outs.Select(a => a.Role == StreamRole.Commanded
            ? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
            : a).ToList();
    }
```

> **Controller 변경 없음**: §0에서 `MapColumn`이 이미 `thetaSuggestUpSec/DnSec/Conf`를 노출한다.

---

## STEP 5 — `ff.js` : θ 제안 표시

**파일**: `src/Web/wwwroot/js/ff.js`

### 5.1 θ 제안 const 추가 (return 직전)

**찾기**:
```javascript
  return `
  <div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
```

**바꾸기**:
```javascript
  const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
  const theta = thetaSug.length
    ? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
    : '';
  return `
  <div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
```

### 5.2 카드 본문에 ${theta} 삽입

> 전제: WO-2에서 mb 아래에 `${temps}`가 이미 들어가 있다.

**찾기**:
```javascript
    <div class="ff-mb">${esc(mb)}</div>
    ${temps}
    <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
```

**바꾸기**:
```javascript
    <div class="ff-mb">${esc(mb)}</div>
    ${temps}
    ${theta}
    <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
```

---

## STEP 6 — `ff.css` : θ 행 스타일

**파일**: `src/Web/wwwroot/css/ff.css`

**파일 맨 끝에 추가**:
```css
/* WO-3 θ 자동튜닝 제안 행 */
.ff-theta{font-size:12px;color:#cdb4ff;margin-top:6px}
.ff-theta small{color:var(--t2)}
```

---

## STEP 7 — 신규 테스트 `FeedforwardThetaTests.cs`

**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs`

```csharp
using System;
using ExperionCrawler.Infrastructure.Control;
using Xunit;

namespace ExperionCrawler.Tests;

public class FeedforwardThetaTests
{
    // 알려진 지연(5 샘플)으로 응답이 피드를 따라가면 θ≈5*ts 로 식별되어야 함
    [Fact]
    public void Estimator_finds_known_lag()
    {
        var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
                                            minSignalStd: 1e-9, recomputeEvery: 1);
        var feed = new System.Collections.Generic.List<double>();
        (double thetaUpSec, double thetaDnSec, double conf)? last = null;
        for (int t = 0; t < 400; t++)
        {
            double df = Math.Sin(t * 0.3);              // 풍부한 양/음 외란
            feed.Add(df);
            double dr = t >= 5 ? feed[t - 5] : 0.0;     // 응답 = 피드 5샘플 지연
            last = est.Push(df, dr, 0.0, tsSec: 1.0);   // 스팀 0
        }
        Assert.NotNull(last);
        Assert.InRange(last!.Value.thetaUpSec, 4.0, 6.0);
        Assert.InRange(last!.Value.thetaDnSec, 4.0, 6.0);
        Assert.True(last!.Value.conf > 0.5);
    }

    // 피드 외란이 없으면(평탄) 제안 억제(null)
    [Fact]
    public void Estimator_suppresses_when_no_excitation()
    {
        var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
                                            minSignalStd: 1e-6, recomputeEvery: 1);
        (double, double, double)? last = (0, 0, 0);
        for (int t = 0; t < 200; t++) last = est.Push(0.0, 0.0, 0.0, 1.0);  // Δ 전부 0
        Assert.Null(last);
    }
}
```

---

## STEP 8 — 검증 (반드시 실행하고 결과 첨부)

```bash
# 1) 빌드
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
# 2) 테스트 — WO-2까지 12 + WO-3 신규 2 = 14
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
# 3) JS 문법
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 4) 쓰기 불변식(FF 경로 0건)
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
# 5) config θ 무변경 불변식 — 엔진이 cfg.Theta*를 쓰기(대입)하지 않는지
grep -nE "cfg\.(ThetaUpSec|ThetaDnSec)\s*=" src/Infrastructure/Control/*.cs || echo "config theta 무변경 OK"
```

**기대 결과**:
| 항목 | 기대 |
|:--|:--|
| 빌드 | `0 Warning(s) 0 Error(s)` |
| 테스트 | `Passed! - Failed: 0, Passed: 14` |
| JS | `JS OK` |
| 쓰기 | `WRITE 0건 OK` |
| config θ | `config theta 무변경 OK` |

### 런타임 확인(선택)
- `ff_column_config`에 `theta_auto_tune=TRUE`, `steam_op_tag='tica-6111a.op'`, `sensitive_tray_tag='ti-6111c'`, `temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5` 설정.
- 외란 충분히 누적(~1시간)된 뒤 카드에 "θ 제안 P ↑NNs ↓NNs conf 0.x" 표시. **config θ는 그대로**(제안만).

---

## 감독자 Sign-off

| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| CrossCorrLagEstimator: 알려진 지연 식별 | ✅ | windpacer 2026-05-31 |
| 외란 부족/저신뢰 시 null 억제 | ✅ | windpacer 2026-05-31 |
| 부분상관으로 스팀 제거(폐루프 오염 회피) | ✅ | windpacer 2026-05-31 |
| SteamOpTag을 .pv 강제 없이 SampleExact로 읽음 | ✅ | windpacer 2026-05-31 |
| **config θ 무변경**(제안 전용) | ✅ | windpacer 2026-05-31 |
| ThetaAutoTune=false면 완전 무동작(옵트인) | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 14/14 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |

---

## 주의(약한 LLM이 흔히 깨먹는 지점)
1. **config θ에 대입 금지** — `cfg.ThetaUpSec = ...` 같은 코드 절대 금지. `StreamAdvisory.ThetaSuggest*`(제안)에만 쓴다.
2. **SteamOpTag은 .op** — `Sample()`(=.pv 강제) 쓰지 말고 `SampleExact()`로. 실측 태그 접미사 확인.
3. **WO-2 선행 필수** — `BuildTemps`/`PvSnapshot.Temps`/`ColumnState.PRef*`가 없으면 앵커가 안 맞는다. WO-2 먼저.
4. **positional record 금지** — `PvSnapshot.SteamOp`는 init 프로퍼티로(생성자 인자 추가 금지). 생성은 `new PvSnapshot(...) { Temps=.., SteamOp=.. }`.
5. **테스트는 estimator를 직접** 호출(엔진 경유 X) — Δ를 직접 Push. recomputeEvery=1로 즉시 계산.
6. **첫 제안까지 시간** — maxLag*2 샘플 누적 전엔 null(정상). 실운전 ~1시간. 조급해하지 말 것.
</content>
