# WO-4 (P-4 느린 바이어스 적응) — 완전코드 작업지시서

> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
> **선행 완료 전제**: §0 + WO-1 + WO-2 + WO-3 머지 완료. `ColumnConfig.BiasMaWindowSec`, `AdvisoryResult.VLossMa`,
> `StreamAdvisory.KObsSuggest`, `MovingAverage`(ComputationBlocks)는 **이미 존재**(다시 만들지 말 것).
> **불변식**: advisory — 쓰기 0건. K_obs·V_loss는 **장기 MA "제안/추세"** 일 뿐 엔진 K(=config TargetCoeff)는 변경 안 함.

## 목적
계절 CW 스윙 등 **크지만 느린 외란**(spec §14.4)을 정밀모델 대신 **장기 이동평균**으로 흡수.
- `V_loss`는 순간값 신뢰불가(§5.3·§14.3 B등급) → **장기 MA(VLossMa)** 로만 의미 → 대시보드 표시 + **WO-6 트리거 입력**.
- commanded 스트림별 **K_obs = PV/FEED_filtered 의 MA** → config K와 비교해 계절 드리프트 "제안".
- **정상상태에서만 누적**(transient·BAD 제외) → 과도 표본 오염 방지.

## 변경 파일 (총 4개)
1. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` MA 필드 + `ApplyBias` + Tick 배선
2. `src/Web/wwwroot/js/ff.js` — VLossMa·KObs 표시 (Controller는 §0에서 `vLossMa`/`kObsSuggest` 이미 노출 — **변경 없음**)
3. `src/Web/wwwroot/css/ff.css` — 바이어스 행 스타일
4. `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs` — **신규** 테스트

---

## STEP 1 — `FeedforwardEngine.cs`

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

### 1.1 `ColumnState`에 MA 상태 추가

> 전제: WO-3에서 `PrevSteamOp` / `ThetaEst` 등이 이미 추가됨.

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

**바꾸기**:
```csharp
    public double        PrevSteamOp      { get; set; } = double.NaN;
    // WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적)
    public MovingAverage? VLossMaBlock { get; set; }
    public Dictionary<string, MovingAverage> KObsMa { get; } = new();
    public Dictionary<string, StreamState> Streams { get; } = new();
```

### 1.2 Tick 배선 — return 직전, θ 제안 다음

> 전제: WO-3 이후 return 영역은 아래와 같다.

**찾기**:
```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 };
```

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

### 1.3 `ApplyBias` 메서드 추가 (ApplyThetaSuggestion 바로 뒤)

> 전제: WO-3가 추가한 `ApplyThetaSuggestion`은 `.ToList();` + `}` 로 끝난다(아래 앵커는 그 마지막 2줄).

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

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

    // ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ──────
    private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss,
        bool transient, ref List<StreamAdvisory> outs, out double? vLossMa)
    {
        int window = Math.Max(1, (int)Math.Round(cfg.BiasMaWindowSec / Math.Max(1e-6, cfg.ScanSec)));
        vLossMa = null;

        // V_loss 장기 MA (정상상태 + vloss 산출된 경우에만 누적)
        if (!transient && vloss.HasValue && Num.IsFinite(vloss.Value))
        {
            st.VLossMaBlock ??= new MovingAverage(window);
            vLossMa = st.VLossMaBlock.Push(vloss.Value);
        }
        else if (st.VLossMaBlock is not null)
        {
            vLossMa = st.VLossMaBlock.Value;   // 과도 중엔 갱신 없이 직전 MA 유지(표시 연속성)
        }

        // commanded 스트림별 K_obs = PV/FF 의 MA → 제안
        if (transient || ff <= 1e-6) return;
        outs = outs.Select(a =>
        {
            if (a.Role != StreamRole.Commanded) return a;
            if (!(pv.Streams.TryGetValue(a.Key, out var smp) && smp.Good && Num.IsFinite(smp.Value))) return a;
            if (!st.KObsMa.TryGetValue(a.Key, out var ma)) { ma = new MovingAverage(window); st.KObsMa[a.Key] = ma; }
            double kObs = ma.Push(smp.Value / ff);
            return a with { KObsSuggest = kObs };
        }).ToList();
    }
```
> **`MovingAverage`에 `Value` 프로퍼티가 없으면** 추가 필요. 확인: 현재 `MovingAverage`는 `Push`만 있고 `Value`가 없을 수 있다 → STEP 1.4 참조.

### 1.4 `MovingAverage.Value` 보강 (필요 시)

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

**찾기**:
```csharp
    public double Push(double x)
    {
        _buf.Enqueue(x); _sum += x;
        while (_buf.Count > _window) _sum -= _buf.Dequeue();
        return _sum / _buf.Count;
    }
```

**바꾸기**:
```csharp
    public double Value => _buf.Count > 0 ? _sum / _buf.Count : double.NaN;
    public double Push(double x)
    {
        _buf.Enqueue(x); _sum += x;
        while (_buf.Count > _window) _sum -= _buf.Dequeue();
        return _sum / _buf.Count;
    }
```

---

## STEP 2 — `ff.js` : VLossMa·KObs 표시

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

### 2.1 mb 문자열에 VLossMa 추가

**찾기**:
```javascript
  const mb = `물질수지: ${esc(c.massBalanceState)}` +
    (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
    (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
```

**바꾸기**:
```javascript
  const mb = `물질수지: ${esc(c.massBalanceState)}` +
    (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
    (c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') +
    (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
```

### 2.2 스트림 행에 KObs 제안 (신뢰 셀 title에 병기)

**찾기**:
```javascript
      <td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span></td>
```

**바꾸기**:
```javascript
      <td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}</td>
```

---

## STEP 3 — `ff.css`

**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
```css
/* WO-4 K_obs 제안 */
.ff-kobs{color:#9fd;opacity:.8}
```

---

## STEP 4 — 신규 테스트 `FeedforwardBiasTests.cs`

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

```csharp
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;

namespace ExperionCrawler.Tests;

public class FeedforwardBiasTests
{
    private static ColumnConfig Cfg() => new()
    {
        Id = 1, Name = "C-BIAS", Enabled = true, FeedTag = "f", ProductKey = "P",
        ScanSec = 2, BiasMaWindowSec = 20,   // 10 샘플 창
        Streams = new[]
        {
            new StreamConfig { Key = "P", FlowTag = "p", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 },
            new StreamConfig { Key = "D", FlowTag = "d", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.02 },
            new StreamConfig { Key = "B", FlowTag = "b", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.03 },
        }
    };

    // FEED 100 고정, P=95 → K_obs ≈ 0.95, D/B는 물질수지 충족용
    private static PvSnapshot Snap() => new(
        new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
        new Dictionary<string, TagSample> {
            ["P"] = new("p", 95, true, DateTime.UtcNow),
            ["D"] = new("d", 2,  true, DateTime.UtcNow),
            ["B"] = new("b", 3,  true, DateTime.UtcNow),
        });

    [Fact]
    public void KObs_and_VLossMa_accumulate_in_steady_state()
    {
        var engine = new FeedforwardEngine();
        var st = new ColumnState();
        AdvisoryResult res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
        for (int i = 0; i < 20; i++) res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);

        var p = res.Streams.Find(s => s.Key == "P")!;
        Assert.NotNull(p.KObsSuggest);
        Assert.InRange(p.KObsSuggest!.Value, 0.94, 0.96);   // 95/100

        Assert.NotNull(res.VLossMa);
        Assert.InRange(res.VLossMa!.Value, -0.5, 0.5);      // 100-(95+2+3)=0
    }
}
```

---

## STEP 5 — 검증

```bash
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
grep -nE "cfg\.TargetCoeff\s*=|s\.TargetCoeff\s*=" src/Infrastructure/Control/*.cs || echo "config K 무변경 OK"
```

**기대**: 빌드 0/0 · 테스트 **15/15**(WO-3까지 14 + 신규 1) · JS OK · 쓰기 0건 · config K 무변경 OK.

---

## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| 정상상태에서만 MA 누적(과도 표본 배제) | ✅ | windpacer 2026-05-31 |
| K_obs = PV/FF MA, config K 무변경 | ✅ | windpacer 2026-05-31 |
| VLossMa 산출(WO-6 트리거 입력) | ✅ | windpacer 2026-05-31 |
| MovingAverage.Value 보강 | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 15/15 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |

## 주의(약한 LLM 함정)
1. **config K(TargetCoeff) 대입 금지** — `KObsSuggest`에만 쓴다(제안).
2. **과도 중 MA 갱신 금지** — `transient` 시 Push 안 함(직전 값만 표시).
3. **MovingAverage.Value** 없으면 STEP 1.4로 보강(빌드 에러 방지).
4. positional record 인자추가 금지 — `VLossMa`/`KObsSuggest`는 init 프로퍼티(§0 기존).
</content>
