packages feed

hgg-core-0.1.0.0: src/Graphics/Hgg/Validate.hs

-- |
-- Module      : Graphics.Hgg.Validate
-- Description : Layer 3.5 ─ compile / validate / 診断 (Phase 11 A1 core hardening)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- 設計方針:
--
--   * 「'VisualSpec' は直接描画しない」 を型で固定する。 backend に渡す前に
--     'compilePlot' を通し、 必須 aesthetic 欠落 / 列解決失敗 / 型不一致 を検出。
--   * 診断は **actionable** であること (= 「Missing y」 ではなく
--     「scatter は x と y が必要。 y 列が未指定。 `y "yield"` を足してください」)。
--   * 列名解決失敗には **編集距離 suggestion** を添える ('validatePlotWith' に
--     既知列名を渡したとき)。
--   * 'BackendCapability' で backend 非対応機能を compile 時に検出する。
--
-- 本 module は render を一切呼ばない (= 出力中立)。 既存 backend は当面そのまま
-- 動き、 段階的に 'compilePlot' 経由へ寄せる。
--
-- 既知の制約: 「1 layer に mark 2 個 (`scatter x y <> line x y`) を合成して 2 個目が
-- 黙って消える」 footgun は、 'Layer' の `lyKind :: First MarkKind` が合成時点で
-- 不可逆に潰れるため **post-hoc には検出できない**。 検出には Layer に診断用
-- フィールドを足す必要があり、 Phase 11 A2 (Monoid 明文化) で扱う。
{-# LANGUAGE OverloadedStrings #-}
module Graphics.Hgg.Validate
  ( -- * Aesthetic / 型
    Aesthetic(..)
  , aesName
  , ExpectedType(..)
  , ActualType(..)
    -- * 診断
  , Severity(..)
  , DiagnosticContext(..)
  , PlotErrorKind(..)
  , PlotWarningKind(..)
  , PlotDiagnostic(..)
  , diagnosticSeverity
  , renderDiagnostic
    -- * 必須 aesthetic
  , requiredAes
  , layerCols
    -- * validate / compile
  , validatePlot
  , validatePlotWith
  , suggest
  , CompiledPlot
  , compiledSpec
  , compilePlot
  , compilePlotWith
    -- * Backend capability matrix (= §5.5)
  , BackendName(..)
  , FeatureName(..)
  , BackendCapability(..)
  , svgCapability
  , pngCapability
  , pdfCapability
  , canvasCapability
  , webglCapability
  , checkCapability
  ) where

import           Data.List   (foldl', sortOn)
import           Data.Maybe  (isJust, mapMaybe)
import           Data.Monoid (First (..), Last (..))
import           Data.Text   (Text)
import qualified Data.Text   as T

import           Graphics.Hgg.Spec

-- ===========================================================================
-- Aesthetic / 型
-- ===========================================================================

-- | mark が要求しうる aesthetic 種別 (= 診断メッセージ用)。
data Aesthetic
  = AesX | AesY | AesY2 | AesColor | AesErrorX | AesErrorY
  | AesSize | AesShape | AesDAG | AesCols
  | AesU | AesV   -- Phase 26 A2: vector field (quiver) の成分
  deriving (Show, Eq)

-- | 診断文に出す aesthetic 名 (= setter 名に寄せる)。
aesName :: Aesthetic -> Text
aesName a = case a of
  AesX      -> "x"
  AesY      -> "y"
  AesY2     -> "y2 (upper)"
  AesColor  -> "color"
  AesErrorX -> "errorX"
  AesErrorY -> "errorY"
  AesSize   -> "size"
  AesShape  -> "shape"
  AesDAG    -> "dag"
  AesCols   -> "columns"
  AesU      -> "u"
  AesV      -> "v"

data ExpectedType = ExpNumeric | ExpCategorical | ExpAny
  deriving (Show, Eq)

data ActualType = ActNumeric | ActCategorical | ActUnresolved
  deriving (Show, Eq)

-- ===========================================================================
-- 診断
-- ===========================================================================

data Severity = SevError | SevWarning | SevInfo
  deriving (Show, Eq, Ord)

-- | どの layer / mark で起きたか (= メッセージの文脈)。
data DiagnosticContext = DiagnosticContext
  { dcLayer :: Maybe Int        -- ^ 0 始まりの layer index (Nothing = 図全体)
  , dcMark  :: Maybe MarkKind
  } deriving (Show, Eq)

topCtx :: DiagnosticContext
topCtx = DiagnosticContext Nothing Nothing

data PlotErrorKind
  = MissingAesthetic MarkKind Aesthetic
  | ColumnNotFound Text [Text]          -- ^ 見つからない列名 + 候補 (編集距離)
  | ColumnTypeMismatch Text Aesthetic ExpectedType ActualType
  | EmptyPlot                           -- ^ layer が 1 つも無い
  | DistColsNonDistribution MarkKind    -- ^ ★ Phase 36 D3: distCols のレーンが分布 mark でない
  deriving (Show, Eq)

data PlotWarningKind
  = BackendUnsupported BackendName FeatureName
  | TooFewColumns Aesthetic Int Int     -- ^ 必要数 / 実数 (parallel 等)
  deriving (Show, Eq)

data PlotDiagnostic
  = PlotError   PlotErrorKind   DiagnosticContext
  | PlotWarning PlotWarningKind DiagnosticContext
  | PlotInfo    Text
  deriving (Show, Eq)

diagnosticSeverity :: PlotDiagnostic -> Severity
diagnosticSeverity PlotError{}   = SevError
diagnosticSeverity PlotWarning{} = SevWarning
diagnosticSeverity PlotInfo{}    = SevInfo

-- | 人間が読める actionable メッセージ (= §5.4 Diagnostics Policy)。
renderDiagnostic :: PlotDiagnostic -> Text
renderDiagnostic d = case d of
  PlotError k ctx   -> sev "error"   <> ctxStr ctx <> errMsg k
  PlotWarning k ctx -> sev "warning" <> ctxStr ctx <> warnMsg k
  PlotInfo t        -> sev "info" <> t
 where
  sev s = "[" <> s <> "] "
  ctxStr (DiagnosticContext ml mm) =
    let lp = maybe "" (\i -> "layer " <> tshow i <> " ") ml
        mp = maybe "" (\m -> "(" <> markName m <> ") ") mm
    in lp <> mp
  errMsg k = case k of
    MissingAesthetic m a ->
      markName m <> " は " <> reqList m <> " が必要です。 "
        <> aesName a <> " が未指定です。 setter `" <> aesName a
        <> " ...` を足してください。"
    ColumnNotFound n [] ->
      "列 \"" <> n <> "\" が Resolver で解決できません。 列名と Resolver の供給列を確認してください。"
    ColumnNotFound n cs ->
      "列 \"" <> n <> "\" が見つかりません。 もしかして: "
        <> T.intercalate " / " (map (\c -> "\"" <> c <> "\"") cs) <> " ?"
    ColumnTypeMismatch n a exp_ act ->
      "列 \"" <> n <> "\" を " <> aesName a <> " に使えません。 "
        <> expName exp_ <> " が必要ですが " <> actName act <> " でした。"
    EmptyPlot ->
      "layer が 1 つもありません。 `layer (scatter x y)` 等を合成してください。"
    DistColsNonDistribution m ->
      "distCols のレーンは 1D 分布 mark (box/violin/strip/swarm/raincloud) 専用です。 "
        <> markName m <> " は描画されません。 レーンを分布 mark にしてください。"
  warnMsg k = case k of
    BackendUnsupported b f ->
      backendName b <> " backend は " <> featureName f
        <> " 非対応です (fallback または無視されます)。"
    TooFewColumns a need got ->
      aesName a <> " は最低 " <> tshow need <> " 列必要ですが " <> tshow got <> " 列でした。"
  expName ExpNumeric     = "数値列"
  expName ExpCategorical = "カテゴリ列"
  expName ExpAny         = "任意の列"
  actName ActNumeric     = "数値列"
  actName ActCategorical = "カテゴリ (文字列) 列"
  actName ActUnresolved  = "未解決"
  reqList m = T.intercalate "+" (map aesName (requiredAes m))

tshow :: Show a => a -> Text
tshow = T.pack . show

markName :: MarkKind -> Text
markName = T.pack . drop 1 . show   -- "MScatter" -> "Scatter"

-- ===========================================================================
-- 必須 aesthetic (= constructor 定義から導出した事実、 Spec.hs L673-993)
-- ===========================================================================

-- | mark が描画に最低限要求する aesthetic。 これが欠けると 'validatePlot' が
-- 'MissingAesthetic' を返す。 categorical/numeric の別は型チェックで別途見る。
requiredAes :: MarkKind -> [Aesthetic]
requiredAes m = case m of
  MScatter    -> [AesX, AesY]
  MLine       -> [AesX, AesY]
  MBar        -> [AesX, AesY]
  MStep       -> [AesX, AesY]
  MStem       -> [AesX, AesY]
  MPie        -> [AesX, AesY]
  MWaterfall  -> [AesX, AesY]
  MTrace      -> [AesX, AesY]
  MViolin     -> [AesX, AesY]
  MStrip      -> [AesX, AesY]
  MSwarm      -> [AesX, AesY]
  MRaincloud  -> [AesX, AesY]
  MRidge      -> [AesX, AesY]
  MEss        -> [AesX, AesY]
  MHistogram  -> [AesX]
  MDensity    -> [AesX]
  MFreqPoly   -> [AesX]
  MAutocorr   -> [AesX]
  MBox        -> [AesY]
  MStatMean   -> [AesY]
  MStatMedian -> [AesY]
  MBand       -> [AesX, AesY, AesY2]
  MContour    -> [AesX, AesY, AesColor]
  MContourFilled -> [AesX, AesY, AesColor]
  MBin2d      -> [AesX, AesY]   -- z (AesColor) は任意 (無ければ count = geom_bin2d 既定)
  MTile       -> [AesX, AesY]   -- Phase 60: fill (AesColor) は任意 (colorBy で離散/連続)
  MHexbin     -> [AesX, AesY]   -- Phase 40: count は自動集計 (z 不要)
  MHeatmap    -> [AesX, AesY, AesColor]
  MCount      -> [AesX, AesY]
  MForest     -> [AesX, AesY, AesErrorX]
  MFunnel     -> [AesX, AesY]
  MParallel   -> [AesCols]
  MDAG        -> [AesDAG]
  MScatter3D  -> [AesX, AesY]
  -- Phase 11 A6: geom_text / geom_label (label 列は Aes ではないので x/y のみ)
  MText       -> [AesX, AesY]
  MLabel      -> [AesX, AesY]
  -- Phase 11 A6-2: geom_qq は sample 列のみ (encY)、 x は理論分位点を内部算出
  MQQ         -> [AesY]
  -- Phase 11 A6-4: stat_ecdf は sample 列のみ (encX)、 y は #(≤x)/n を内部算出
  MEcdf       -> [AesX]
  -- Phase 11 A6-4b: 区間 geom は x/y/errorY (= y±err)
  MLineRange  -> [AesX, AesY, AesErrorY]
  MPointRange -> [AesX, AesY, AesErrorY]
  MCrossbar   -> [AesX, AesY, AesErrorY]
  -- 半導体特化 (spec のみ、 render 未): 暫定の最小要求
  MWaferMap   -> [AesX, AesY]
  MControl    -> [AesY]
  -- Phase 16: stat-in (= ggplot stat_smooth)。 x/y 必須。 描画前に bridge resolveStats が
  -- band/line に展開する (未解決のまま描くと renderer は skip)。
  MStatLM     -> [AesX, AesY]
  MStatSmooth -> [AesX, AesY]
  -- Phase 16 B3: 多項式回帰 / 残差診断。 ともに x/y 必須。 resolveStats が band+line / scatter に展開。
  MStatPoly   -> [AesX, AesY]
  MStatResid  -> [AesX, AesY]
  -- Phase 52.D2: streamgraph は x/y/color (= 系列分割) 必須
  MStream     -> [AesX, AesY, AesColor]
  -- Phase 26 A2: vector field (quiver) は x/y/u/v 必須
  MQuiver     -> [AesX, AesY, AesU, AesV]
  -- Phase 51: custom mark は必須 aesthetic なし (データは closure/resolver/options 経由)。
  MCustom     -> []

-- ===========================================================================
-- validate
-- ===========================================================================

-- | 既知列名なしの検証 (= 列解決の成否のみ、 suggestion 無し)。
validatePlot :: Resolver -> VisualSpec -> [PlotDiagnostic]
validatePlot = validatePlotWith []

-- | 既知列名 (= Resolver が供給できる列の一覧) を渡すと 'ColumnNotFound' に
-- 編集距離 suggestion が付く。
validatePlotWith :: [Text] -> Resolver -> VisualSpec -> [PlotDiagnostic]
validatePlotWith known r spec =
  emptyCheck ++ layerDiags ++ subDiags
 where
  ls = vsLayers spec
  emptyCheck
    | null ls && null (vsSubplots spec) = [PlotError EmptyPlot topCtx]
    | otherwise                         = []
  layerDiags = concat (zipWith (validateLayer known r) [0 ..] ls)
  -- subplots は独立 spec なので再帰 (layer index は各 sub で 0 始まり)
  subDiags = concatMap (validatePlotWith known r) (vsSubplots spec)

-- | 1 layer の検証: 必須 aesthetic 欠落 + 列解決 + 型チェック。
validateLayer :: [Text] -> Resolver -> Int -> Layer -> [PlotDiagnostic]
validateLayer known r i ly =
  case getFirst (lyKind ly) of
    Nothing   -> []   -- mark 未指定の attribute-only layer (合成途中) はスキップ
    Just mark ->
      let ctx     = DiagnosticContext (Just i) (Just mark)
          present = layerCols ly
          missing =
            [ PlotError (MissingAesthetic mark a) ctx
            | a <- requiredAes mark
            , a `notElem` map fst present
            , a `notElem` [AesDAG, AesCols]   -- DAG/cols は別チェック
            ]
          dagMiss =
            [ PlotError (MissingAesthetic mark AesDAG) ctx
            | AesDAG `elem` requiredAes mark
            , Nothing <- [getLast (lyDAG ly)] ]
          colsMiss =
            [ PlotWarning (TooFewColumns AesCols 2 (length (lyHover ly))) ctx
            | AesCols `elem` requiredAes mark
            , length (lyHover ly) < 2 ]
          resolveDiags = concatMap (uncurry (checkCol known r ctx)) present
          -- ★ Phase 36 D3 ②: distCols(= 合成が複数の値列)のサブマークは 1D 分布 mark 専用。
          distColsDiags
            | length (compositeLanes ly) > 1 =
                [ PlotError (DistColsNonDistribution k) (DiagnosticContext (Just i) (Just k))
                | sub <- ly : lyOverlay ly
                , Just k <- [getFirst (lyKind sub)]
                , k `notElem` [MBox, MViolin, MStrip, MSwarm, MRaincloud] ]
            | otherwise = []
      in missing ++ dagMiss ++ colsMiss ++ resolveDiags ++ distColsDiags

-- | layer に実際に設定済みの (aesthetic, 列) 組を取り出す。
layerCols :: Layer -> [(Aesthetic, ColRef)]
layerCols ly = mapMaybe pick
  [ (AesX,      getLast (lyEncX ly))
  , (AesY,      getLast (lyEncY ly))
  , (AesY2,     getLast (lyEncY2 ly))
  , (AesErrorX, getLast (lyErrorX ly))
  , (AesErrorY, getLast (lyErrorY ly))
  , (AesU,      getLast (lyEncU ly))   -- Phase 26 A2: quiver u
  , (AesV,      getLast (lyEncV ly))   -- Phase 26 A2: quiver v
  ] ++ colorCol
 where
  pick (a, Just c) = Just (a, c)
  pick (_, Nothing) = Nothing
  colorCol = case getLast (lyColor ly) of
    Just (ColorByCol c)        -> [(AesColor, c)]
    Just (ColorByContinuous c) -> [(AesColor, c)]
    _                          -> []

-- | 列の解決可否 + 型チェック。 数値要求 aesthetic に文字列列が来たら型不一致。
checkCol :: [Text] -> Resolver -> DiagnosticContext -> Aesthetic -> ColRef -> [PlotDiagnostic]
checkCol known r ctx aes cr = case cr of
  ColByName n
    | not (isJust (resolveCol r cr)) ->
        [PlotError (ColumnNotFound n (suggest known n)) ctx]
    | otherwise -> typeCheck n
  _ -> typeCheck ""   -- inline は常に解決可、 型のみ
 where
  typeCheck n = case (expectedFor aes, resolveCol r cr) of
    (ExpNumeric, Just (TxtData _)) ->
      [PlotError (ColumnTypeMismatch n aes ExpNumeric ActCategorical) ctx]
    _ -> []

-- | aesthetic が数値を要求するか。 x/y は mark により categorical 可なので緩く ExpAny。
-- color (continuous 経路で来たもの) と error bar は数値必須。
expectedFor :: Aesthetic -> ExpectedType
expectedFor AesErrorX = ExpNumeric
expectedFor AesErrorY = ExpNumeric
expectedFor AesU      = ExpNumeric   -- Phase 26 A2: quiver 成分は数値
expectedFor AesV      = ExpNumeric
expectedFor _         = ExpAny

-- ===========================================================================
-- 編集距離 suggestion (= Levenshtein、 距離 ≤ 3 を近い順に最大 3 件)
-- ===========================================================================

suggest :: [Text] -> Text -> [Text]
suggest known target =
  take 3 . map fst . sortOn snd $
    [ (k, d) | k <- known, let d = levenshtein target k, d <= maxDist ]
 where
  maxDist = max 2 (T.length target `div` 2)

-- 標準 Levenshtein (Rosetta Code Haskell 版): 各行を scanl で構築。
levenshtein :: Text -> Text -> Int
levenshtein a b = last (foldl' transform [0 .. length s1] s2)
 where
  s1 = T.unpack a
  s2 = T.unpack b
  transform prev@(p0 : _) c =
    scanl calc (p0 + 1) (zip3 s1 prev (tail prev))
   where
    calc left (c1, diag, up) =
      minimum [up + 1, left + 1, diag + fromEnum (c1 /= c)]
  transform [] _ = []

-- ===========================================================================
-- compile (= VisualSpec を「検証済」 でラップ)
-- ===========================================================================

-- | 検証を通過した 'VisualSpec'。 backend はこれを受け取る形に寄せられる
-- (現状は 'compiledSpec' で素の VisualSpec を取り出して既存 backend に渡せる)。
newtype CompiledPlot = CompiledPlot { compiledSpec :: VisualSpec }
  deriving (Show)

-- | error が無ければ 'CompiledPlot'、 あれば error 一覧を返す
-- (warning は通過させる)。
compilePlot :: Resolver -> VisualSpec -> Either [PlotDiagnostic] CompiledPlot
compilePlot = compilePlotWith []

compilePlotWith :: [Text] -> Resolver -> VisualSpec
                -> Either [PlotDiagnostic] CompiledPlot
compilePlotWith known r spec =
  case filter ((== SevError) . diagnosticSeverity) (validatePlotWith known r spec) of
    []   -> Right (CompiledPlot spec)
    errs -> Left errs

-- ===========================================================================
-- Backend capability matrix (= §5.5)
-- ===========================================================================

data BackendName = BackendSVG | BackendPNG | BackendPDF | BackendCanvas | BackendWebGL
  deriving (Show, Eq)

backendName :: BackendName -> Text
backendName b = case b of
  BackendSVG    -> "SVG"
  BackendPNG    -> "PNG"
  BackendPDF    -> "PDF"
  BackendCanvas -> "Canvas"
  BackendWebGL  -> "WebGL"

data FeatureName
  = FeatTransparency | FeatHover | FeatInteractive3D | FeatProjected3D
  deriving (Show, Eq)

featureName :: FeatureName -> Text
featureName f = case f of
  FeatTransparency  -> "透明度 (alpha)"
  FeatHover         -> "hover tooltip"
  FeatInteractive3D -> "interactive 3D"
  FeatProjected3D   -> "3D (CPU projection)"

-- | backend ごとの対応機能 (= §5.5)。
data BackendCapability = BackendCapability
  { capName          :: BackendName
  , capTransparency  :: Bool
  , capHover         :: Bool
  , capProjected3D   :: Bool
  , capInteractive3D :: Bool
  } deriving (Show, Eq)

svgCapability, pngCapability, pdfCapability, canvasCapability, webglCapability
  :: BackendCapability
svgCapability    = BackendCapability BackendSVG    True  False True  False
pngCapability    = BackendCapability BackendPNG    True  False True  False
pdfCapability    = BackendCapability BackendPDF    True  False True  False
canvasCapability = BackendCapability BackendCanvas True  True  True  False
webglCapability  = BackendCapability BackendWebGL  True  True  True  True

-- | spec が使う機能のうち backend 非対応なものを warning 化。
checkCapability :: BackendCapability -> VisualSpec -> [PlotDiagnostic]
checkCapability cap spec = concatMap layerCap (vsLayers spec)
                        ++ concatMap (checkCapability cap) (vsSubplots spec)
 where
  b = capName cap
  layerCap ly =
    let ctx = DiagnosticContext Nothing (getFirst (lyKind ly))
        alphaUsed = case getLast (lyAlpha ly) of
          Just a  -> a < 1.0
          Nothing -> False
        hoverUsed = not (null (lyHover ly))
        is3D = getFirst (lyKind ly) == Just MScatter3D
    in  [ PlotWarning (BackendUnsupported b FeatTransparency) ctx
        | alphaUsed, not (capTransparency cap) ]
     ++ [ PlotWarning (BackendUnsupported b FeatHover) ctx
        | hoverUsed, not (capHover cap) ]
     ++ [ PlotWarning (BackendUnsupported b FeatProjected3D) ctx
        | is3D, not (capProjected3D cap) ]