packages feed

agentic-0.2.1.0: src/Agentic/Questions.hs

{-# LANGUAGE AllowAmbiguousTypes #-}

-- | Questions for a System One model such as Jev: a step asks t'Questions'
-- about its input.
module Agentic.Questions
  ( -- * Questions
    Questions (..)
  , yesNo
  , choice
  , score

    -- * Answers
  , Probability
  , probability
  , toProbability
  , basisPoints
  , fromBasisPoints
  , YesNo (..)
  , Choice (..)
  , Score (..)

    -- * Wire types
  , QuestionSpec (..)
  , Answer (..)
  , JudgeRequest (..)
  , decodeAnswers
  ) where

import Agentic.Contract (Codec (..), Contract (..), Option (..), OptionSet (..), Options (..), ScalarInfo (..), record, required, scalar)
import Agentic.Schema (Shape (SNumber), schemaOf)
import Agentic.Value (Value (..))
import Data.List (find)
import Data.Text (Text)
import Data.Text qualified as T

-- ---------------------------------------------------------------------------
-- Probabilities

-- | A probability, held as basis points (0–10000) so that results replay and
-- compare exactly. Write literals directly: @0.9 :: Probability@.
newtype Probability = Probability Int
  deriving (Eq, Ord)

instance Show Probability where
  show p = show (probability p)

instance Num Probability where
  Probability a + Probability b = clamp (a + b)
  Probability a - Probability b = clamp (a - b)
  Probability a * Probability b = clamp ((a * b) `div` 10000)
  abs = id
  signum (Probability a) = Probability (if a > 0 then 10000 else 0)
  fromInteger n = clamp (fromInteger n * 10000)

instance Fractional Probability where
  fromRational r = clamp (round (r * 10000))
  Probability a / Probability b = clamp ((a * 10000) `div` max 1 b)

clamp :: Int -> Probability
clamp = Probability . max 0 . min 10000

probability :: Probability -> Double
probability (Probability bp) = fromIntegral bp / 10000

-- | Convert a provider's probability, rounding once (half to even).
toProbability :: Double -> Probability
toProbability d = clamp (round (d * 10000))

basisPoints :: Probability -> Int
basisPoints (Probability bp) = bp

fromBasisPoints :: Int -> Probability
fromBasisPoints = clamp

-- ---------------------------------------------------------------------------
-- Answers

-- | Jev's Noul: the probability that the answer is yes.
newtype YesNo = YesNo {yes :: Probability}
  deriving (Eq, Show)

data Choice a = Choice
  { chosen :: a
  , probabilities :: [(a, Probability)]
  , confidence :: Probability
  }
  deriving (Eq, Show)

data Score a = Score
  { position :: Double
  -- ^ The probability-weighted position, from 0 (the first option) upwards.
  , probabilities :: [(a, Probability)]
  , confidence :: Probability
  }
  deriving (Eq, Show)

-- Answers have contracts, so a judgement can be a tool's output.

instance Contract Probability where
  contract =
    scalar (ScalarInfo "Agentic.Questions.Probability" []) $
      Just $
        Codec
          (schemaOf SNumber)
          (Number . probability)
          ( \case
              Number d | not (isNaN d || isInfinite d) -> Right (if d <= 0 then 0 else if d >= 1 then 1 else toProbability d)
              Integer n -> Right (if n <= 0 then 0 else 1)
              _ -> Left "expected a finite probability"
          )

instance Contract YesNo where
  contract = record "A yes/no judgement" (YesNo <$> required "yes" "The probability that the answer is yes" (.yes))

instance Contract a => Contract (Choice a) where
  contract =
    record "A choice between options" $
      Choice
        <$> required "chosen" "The most likely option" (.chosen)
        <*> required "probabilities" "Each option's probability" (.probabilities)
        <*> required "confidence" "How concentrated the probabilities are" (.confidence)

instance Contract a => Contract (Score a) where
  contract =
    record "A position on ordered levels" $
      Score
        <$> required "position" "The probability-weighted position, from 0 upwards" (.position)
        <*> required "probabilities" "Each level's probability" (.probabilities)
        <*> required "confidence" "How concentrated the probabilities are" (.confidence)

-- ---------------------------------------------------------------------------
-- Wire types

data QuestionSpec
  = AskYesNo Text
  | -- | Option labels with their descriptions.
    AskChoice Text [(Text, Maybe Text)]
  | -- | Levels in order, lowest first.
    AskScore Text [(Text, Maybe Text)]
  deriving (Eq, Ord, Show)

data Answer
  = YesNoAnswer Probability
  | -- | The chosen label, every label's probability, and the confidence.
    ChoiceAnswer Text [(Text, Probability)] Probability
  | -- | The position, each level's probability (by index), and the confidence.
    ScoreAnswer Double [(Int, Probability)] Probability
  deriving (Eq, Show)

-- | What a System One provider receives: the encoded input and the questions.
data JudgeRequest = JudgeRequest
  { input :: Value
  , questions :: [QuestionSpec]
  }
  deriving (Eq, Ord, Show)

-- ---------------------------------------------------------------------------
-- Questions

-- | One or more questions about the same input, sent as one request. Combine
-- them applicatively:
--
-- > judge (Review <$> funny <*> groan)
data Questions a = Questions
  { specs :: [QuestionSpec]
  , decoder :: [Answer] -> Either Text a
  }

instance Functor Questions where
  fmap f q = q{decoder = fmap f . q.decoder}

instance Applicative Questions where
  pure x = Questions [] (\case [] -> Right x; _ -> Left "too many answers")
  Questions l dl <*> Questions r dr = Questions (l <> r) $ \answers ->
    let (before, after) = splitAt (length l) answers
     in dl before <*> dr after

decodeAnswers :: Questions a -> [Answer] -> Either Text a
decodeAnswers = (.decoder)

single :: QuestionSpec -> (Answer -> Either Text a) -> Questions a
single spec decode = Questions [spec] $ \case
  [answer] -> decode answer
  answers -> Left ("expected one answer, got " <> T.pack (show (length answers)))

-- | Jev's Noul primitive: how likely is it that the answer is yes?
yesNo :: Text -> Questions YesNo
yesNo q = single (AskYesNo q) $ \case
  YesNoAnswer p -> Right (YesNo p)
  other -> Left ("expected a yes/no answer, got " <> T.pack (show other))

-- | Pick one of an 'Options' type's values.
choice :: forall a. Options a => Text -> Questions (Choice a)
choice q = single (AskChoice q (labels opts)) $ \case
  ChoiceAnswer picked ps conf ->
    Choice <$> byLabel opts picked <*> traverse (\(l, p) -> (,p) <$> byLabel opts l) ps <*> pure conf
  other -> Left ("expected a choice answer, got " <> T.pack (show other))
 where
  opts = (options @a).options

-- | Place the input on an 'Options' type's levels, lowest first.
score :: forall a. Options a => Text -> Questions (Score a)
score q = single (AskScore q (labels opts)) $ \case
  ScoreAnswer pos ps conf ->
    Score pos <$> traverse (\(i, p) -> (,p) <$> byIndex i) ps <*> pure conf
  other -> Left ("expected a score answer, got " <> T.pack (show other))
 where
  opts = (options @a).options
  byIndex i = case drop i opts of
    o : _ | i >= 0 -> Right o.value
    _ -> Left ("no level " <> T.pack (show i))

labels :: [Option a] -> [(Text, Maybe Text)]
labels = map (\o -> (o.label, o.doc))

byLabel :: [Option a] -> Text -> Either Text a
byLabel opts l = maybe (Left ("unknown option " <> l)) (Right . (.value)) (find ((== l) . (.label)) opts)