packages feed

agentic-0.2.1.0: src/Agentic/Contract.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE TypeOperators #-}

-- | One typed structural contract, interpreted into independently chosen codecs.
module Agentic.Contract
  ( Contract (..)
  , Structure (..)
  , Fields (..)
  , Case (..)
  , Constructor (..)
  , FieldStyle (..)
  , ScalarInfo (..)
  , Outline (..)
  , OutlineField (..)
  , OutlineCase (..)
  , Codec (..)
  , ProjectionError (..)
  , compileModel
  , compileWith
  , compileFieldsWith
  , compileCaseWith
  , outline
  , outlineLabel
  , scalar
  , nominal
  , mapStructure
  , mapCodec
  , reschema
  , record
  , required
  , requiredWith
  , optional
  , lmapObject
  , sumOf
  , constructor
  , documented
  , field
  , checked
  , between
  , genericContract
  , GContract (..)
  , GCases (..)
  , GFields (..)
  , GEnum (..)
  , Options (..)
  , OptionSet (..)
  , Option (..)
  , option
  , documentedOptions
  , enumeration
  ) where

import Agentic.Codec (Codec (..), mapCodec, reschema)
import Agentic.Codec qualified as C
import Agentic.Schema hiding (Field)
import Agentic.Schema qualified as Schema
import Agentic.Value
import Data.Kind (Type)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics hiding (Constructor)
import GHC.Generics qualified
import Type.Reflection

-- | Stable semantic identity chosen by the definer and explicit parameters.
-- The identity need not be a Haskell module path. Type evidence is
-- carried separately by 'Scalar'; do not identify scalars by an unqualified name.
data ScalarInfo = ScalarInfo {name :: Text, parameters :: [(Text, Text)]}
  deriving (Eq, Show)

-- | Whether the author declared named record fields or positional arguments.
data FieldStyle = RecordFields | PositionalFields deriving (Eq, Show)

-- | The declared constructor name and field style, independent of wire layout.
data Constructor = Constructor {name :: Text, style :: FieldStyle} deriving (Eq, Show)

-- | Executable structure. No projection is chosen by constructing this tree.
-- Mappings preserve the inner structure; use Named for a nominal wrapper and
-- Scalar for a deliberately opaque semantic leaf.
data Structure a where
  Scalar :: Typeable a => ScalarInfo -> Maybe (Codec a) -> Structure a
  Named :: TypeRep a -> Structure a -> Structure a
  Record :: Constructor -> Fields a a -> Structure a
  Sum :: [Case a] -> Structure a
  List :: Structure a -> Structure [a]
  Optional :: Structure a -> Structure (Maybe a)
  Unit :: Structure ()
  Mapped :: (a -> b) -> (b -> a) -> Structure a -> Structure b
  Checked :: Text -> (a -> Bool) -> Structure a -> Structure a
  Documented :: Text -> Structure a -> Structure a

-- | Applicative fields retain getters, reconstruction, and declaration order.
data Fields r a where
  Pure :: a -> Fields r a
  Apply :: Fields r (a -> b) -> Fields r a -> Fields r b
  Field :: Text -> Maybe Text -> Structure a -> (r -> a) -> Fields r a

instance Functor (Fields r) where
  fmap f x = Apply (Pure f) x
instance Applicative (Fields r) where
  pure = Pure
  (<*>) = Apply

-- | Adapt the input seen by field getters, retaining their order and reconstruction.
lmapObject :: (s -> r) -> Fields r a -> Fields s a
lmapObject f = \case
  Pure a -> Pure a
  Apply g x -> Apply (lmapObject f g) (lmapObject f x)
  Field n d c get -> Field n d c (get . f)

-- | A sum case exposes its payload only after successful matching. The payload
-- type is existential: generic field getters cannot be applied to another arm.
data Case a where
  Case ::
    { constructor :: Constructor
    , doc :: Maybe Text
    , match :: a -> Maybe p
    , inject :: p -> a
    , fields :: Fields p p
    } ->
    Case a

-- | A single structural authority for discovery and independently compiled codecs.
-- Derive with Generic, or build a tree explicitly with record, sumOf and scalar.
class Contract a where
  -- | The typed description; no storage or model projection is selected yet.
  contract :: Structure a
  default contract :: (Generic a, Typeable a, GContract (Rep a)) => Structure a
  contract = genericContract

-- | Define an identifiable semantic leaf with optional default model behavior.
-- Other interpreters can supply codecs even when that default is absent.
scalar :: Typeable a => ScalarInfo -> Maybe (Codec a) -> Structure a
scalar = Scalar

-- | Preserve a type's resolved nominal identity and applied arguments. Useful
-- around handwritten contracts and mappings; generic derivation adds it itself.
nominal :: forall a. Typeable a => Structure a -> Structure a
nominal = Named (typeRep @a)

-- | Change the Haskell representation without discarding the inner structure.
-- The functions should be inverse on supported values. Wrap with nominal
-- when the outer type has a nominal identity that discovery must preserve.
mapStructure :: (a -> b) -> (b -> a) -> Structure a -> Structure b
mapStructure = Mapped

-- | Close applicative fields into a documented record description. Use Named
-- (or nominal) when a handwritten record also needs nominal identity.
record :: Text -> Fields a a -> Structure a
record d = documented d . Record (Constructor "" RecordFields)

-- | A named, documented field using its type's contract and a getter.
required :: Contract a => Text -> Text -> (r -> a) -> Fields r a
required n d = requiredWith n (nonEmpty d) contract

-- | A named field with an explicit structural description. Missing-field
-- behavior is chosen by the projection; the model permits absent Maybe fields.
requiredWith :: Text -> Maybe Text -> Structure a -> (r -> a) -> Fields r a
requiredWith = Field

-- | A Maybe field. Models use null/absence; other projections may use tags.
optional :: Contract a => Text -> Text -> (r -> Maybe a) -> Fields r (Maybe a)
optional = required

-- | A handwritten sum case with a predicate and applicative fields. Getters
-- must be defined for every value accepted by the predicate. For typed payload
-- matching and injection, use the existential Case constructor directly.
constructor :: Text -> Text -> (a -> Bool) -> Fields a a -> Case a
constructor n d matches = Case (Constructor n RecordFields) (nonEmpty d) (\a -> if matches a then Just a else Nothing) id

-- | Describe an ordered set of sum cases. The interpreter chooses their layout.
sumOf :: Text -> [Case a] -> Structure a
sumOf d = documented d . Sum

-- | Describe the whole contract, without selecting a representation.
documented :: Text -> Structure a -> Structure a
documented "" = id
documented d = Documented d

-- | Describe one field of a record or each matching sum arm. Annotation is
-- applied immediately so it follows layout changes. A missing field raises an
-- error when the annotated structure is first evaluated.
field :: Text -> Text -> Structure a -> Structure a
field n d s = case annotateField n d s of
  Just annotated -> annotated
  Nothing -> error ("Agentic.Contract.field: no field named " <> T.unpack n)

-- | Share a semantic predicate across projections. Decoders reject values that
-- fail it; model schemas also carry the rule as explanatory text.
checked :: Text -> (a -> Bool) -> Structure a -> Structure a
checked = Checked

-- | Require a decoded value to lie within the inclusive bounds.
between :: (Ord a, Show a) => a -> a -> Structure a -> Structure a
between lo hi = checked ("between " <> T.pack (show lo) <> " and " <> T.pack (show hi)) (\a -> a >= lo && a <= hi)

nonEmpty :: Text -> Maybe Text
nonEmpty t = if T.null t then Nothing else Just t

-- | Pure discovery data derived from the structure, independent of any codec.
-- Representation-only mappings are omitted.
data Outline
  = OScalar SomeTypeRep ScalarInfo
  | ONamed SomeTypeRep Outline
  | ORecord Constructor [OutlineField]
  | OSum [OutlineCase]
  | OList Outline
  | OOptional Outline
  | OUnit
  | OChecked Text Outline
  | ODocumented Text Outline
  deriving (Eq, Show)

-- | A field's name, optional documentation, and structural outline.
data OutlineField = OutlineField
  { name :: Text
  , doc :: Maybe Text
  , structure :: Outline
  }
  deriving (Eq, Show)

-- | Constructor metadata, optional documentation, and fields in declaration order.
data OutlineCase = OutlineCase
  { constructor :: Constructor
  , doc :: Maybe Text
  , fields :: [OutlineField]
  }
  deriving (Eq, Show)

-- | Discover a finite contract without compiling or invoking any codec.
-- Functions and predicates remain in the tree rather than this pure view.
outline :: forall a. Structure a -> Outline
outline = \case
  Scalar info _ -> OScalar (SomeTypeRep (typeRep @a)) info
  Named rep c -> ONamed (SomeTypeRep rep) (outline c)
  Record con fs -> ORecord con (describeFields fs)
  Sum cs -> OSum [OutlineCase con d (describeFields fs) | Case con d _ _ fs <- cs]
  List c -> OList (outline c)
  Optional c -> OOptional (outline c)
  Unit -> OUnit
  Mapped _ _ c -> outline c
  Checked rule _ c -> OChecked rule (outline c)
  Documented d c -> ODocumented d (outline c)

describeFields :: Fields r a -> [OutlineField]
describeFields = \case
  Pure _ -> []
  Apply f x -> describeFields f <> describeFields x
  Field n d c _ -> [OutlineField n d (outline c)]

-- | A concise display label. Scalar identity strings remain available separately.
outlineLabel :: Outline -> Text
outlineLabel = \case
  ONamed rep _ -> T.pack (show rep)
  OScalar rep _ -> T.pack (show rep)
  ORecord con _ -> if T.null con.name then "object" else con.name
  OSum _ -> "sum"
  OList c -> "[" <> outlineLabel c <> "]"
  OOptional c -> outlineLabel c <> "?"
  OUnit -> "()"
  OChecked _ c -> outlineLabel c
  ODocumented _ c -> outlineLabel c

-- | Failures are discovered while compiling the entire structure, before values
-- are encoded. Paths identify fields, constructors and container elements.
data ProjectionError = ProjectionError {path :: [Text], problem :: Text}
  deriving (Eq, Show)

-- | Compile the default model representation, rejecting unsupported leaves and
-- lossy nullable combinations across the entire finite tree. Single positional
-- fields are transparent; any named zero-field Record becomes a string enum.
-- All-nullary sums use string enums; other sums use tagged objects.
compileModel :: Structure a -> Either ProjectionError (Codec a)
compileModel = compileWith (const Nothing)

-- | An open interpreter: override any typed node, delegate the remainder to the
-- model rules. An override owns both its schema and codec and must compile all
-- children it supports. The hook is consulted at EVERY node, including Named,
-- Mapped, Checked and Documented. Recursive overrides can call compileWith
-- themselves, or use compileFieldsWith / compileCaseWith for compound nodes.
-- Scalar matching can use eqTypeRep and HRefl, without unchecked casts.
compileWith ::
  (forall x. Structure x -> Maybe (Either ProjectionError (Codec x))) ->
  Structure a ->
  Either ProjectionError (Codec a)
compileWith override = go
 where
  at n = either (\(ProjectionError p e) -> Left (ProjectionError (n : p) e)) Right
  go :: Structure x -> Either ProjectionError (Codec x)
  go tree = case override tree of
    Just result -> result
    Nothing -> case tree of
      Scalar info mc -> maybe (Left (ProjectionError [] ("unsupported model scalar " <> info.name))) Right mc
      Named rep c -> reschema (titled (T.pack (show rep))) <$> go c
      Mapped to' from' c -> mapCodec to' from' <$> go c
      Checked rule ok c -> do
        codec <- go c
        let s = codec.schema
        pure $ Codec (s{checks = s.checks <> [rule]}) codec.encode $ \v -> do
          a <- codec.decode v
          if ok a then Right a else Left ("must be " <> rule)
      Documented d c -> reschema (documentedSchema d) <$> go c
      Unit -> Right (Codec (schemaOf SNull) (const Null) (\v -> case v of Null -> Right (); _ -> Left "expected null"))
      List c -> do
        inner <- at "[]" (go c)
        pure
          ( Codec
              (schemaOf (SArray inner.schema))
              (Array . map inner.encode)
              (\case Array vs -> traverse inner.decode vs; _ -> Left "expected a list")
          )
      Optional c -> do
        inner <- at "?" (go c)
        if acceptsNull inner.schema
          then Left (ProjectionError [] "model optional cannot distinguish nested nullable values")
          else
            pure
              ( Codec
                  (schemaOf (SNullable inner.schema))
                  (maybe Null inner.encode)
                  (\case Null -> Right Nothing; v -> Just <$> inner.decode v)
              )
      Record con fs -> do
        obj <- compileFieldsWith go fs
        let codec = C.record "" obj
        case (con.style, obj.fields) of
          (_, [])
            | not (T.null con.name) ->
                pure $
                  Codec
                    (schemaOf (SEnum [(con.name, Nothing)]))
                    (const (String con.name))
                    (\case String tag | tag == con.name -> codec.decode (Object []); _ -> Left "expected the constructor name")
          (PositionalFields, [Schema.Field n s _]) ->
            pure
              ( Codec
                  s
                  (\a -> case codec.encode a of Object kvs -> maybe Null id (lookup n kvs); _ -> Null)
                  (\v -> codec.decode (Object [(n, v)]))
              )
          _ -> pure codec
      Sum cs -> C.sumOf "" <$> traverse (compileCaseWith go) cs
  acceptsNull s = case s.shape of SNullable _ -> True; SNull -> True; _ -> False

-- Apply field annotations before choosing a layout: an adapter may put fields
-- inside a payload object or use a completely different projected schema.
annotateField :: Text -> Text -> Structure a -> Maybe (Structure a)
annotateField n d = go
 where
  go :: Structure x -> Maybe (Structure x)
  go = \case
    Named rep c -> Named rep <$> go c
    Mapped f g c -> Mapped f g <$> go c
    Checked rule ok c -> Checked rule ok <$> go c
    Documented doc c -> Documented doc <$> go c
    Record con fs | contains fs -> Just (Record con (update fs))
    Sum cs
      | any (\(Case _ _ _ _ fs) -> contains fs) cs ->
          Just (Sum [Case con doc match inject (update fs) | Case con doc match inject fs <- cs])
    _ -> Nothing
  contains :: Fields r x -> Bool
  contains = \case
    Pure _ -> False
    Apply f x -> contains f || contains x
    Field name _ _ _ -> name == n
  update :: Fields r x -> Fields r x
  update = \case
    Pure x -> Pure x
    Apply f x -> Apply (update f) (update x)
    Field name doc c get -> Field name (if name == n then Just d else doc) c get

-- | Compile an applicative field structure using the supplied child interpreter.
-- The resulting ObjectCodec can be assembled with Agentic.Codec builders or
-- used directly for a different object layout. Nullable fields use that codec's
-- schema; an adapter can override requiredness/absence policy if necessary.
compileFieldsWith ::
  (forall x. Structure x -> Either ProjectionError (Codec x)) ->
  Fields r a ->
  Either ProjectionError (C.ObjectCodec r a)
compileFieldsWith interpretField = go
 where
  go :: Fields r x -> Either ProjectionError (C.ObjectCodec r x)
  go = \case
    Pure a -> Right (pure a)
    Apply f x -> (<*>) <$> go f <*> go x
    Field n d c get -> C.requiredWith n d <$> atPath n (interpretField c) <*> pure get

-- | Compile a case with total matching and reconstruction. This does not choose
-- the enclosing sum's wire layout; the caller owns that decision.
compileCaseWith ::
  (forall x. Structure x -> Either ProjectionError (Codec x)) ->
  Case a ->
  Either ProjectionError (C.CaseCodec a)
compileCaseWith interpretField (Case con d match inject fs) = do
  obj <- atPath con.name (compileFieldsWith interpretField fs)
  pure $ C.CaseCodec con.name d obj.fields (fmap obj.encode . match) (fmap inject . obj.decode)

atPath :: Text -> Either ProjectionError a -> Either ProjectionError a
atPath n = either (\(ProjectionError p e) -> Left (ProjectionError (n : p) e)) Right

instance Contract Text where
  contract =
    scalar (ScalarInfo "Data.Text.Text" []) $
      Just $
        C.primitive (SString Nothing) String (\case String s -> Right s; v -> C.mismatch "text" v)
instance Contract Bool where
  contract =
    scalar (ScalarInfo "Prelude.Bool" []) $
      Just $
        C.primitive SBool Bool (\case Bool b -> Right b; v -> C.mismatch "a boolean" v)
instance Contract Integer where
  contract =
    scalar (ScalarInfo "Prelude.Integer" []) $
      Just $
        C.primitive SInteger Integer (\case Integer n -> Right n; Number d | not (isNaN d || isInfinite d), d == fromInteger (round d) -> Right (round d); v -> C.mismatch "an integer" v)
instance Contract Int where
  contract =
    scalar (ScalarInfo "Prelude.Int" []) $
      Just $
        C.primitive
          SInteger
          (Integer . toInteger)
          ( \case
              Integer n | inRange n -> Right (fromInteger n)
              Number d | not (isNaN d || isInfinite d), let n = round d, d == fromInteger n, inRange n -> Right (fromInteger n)
              v -> C.mismatch "an Int" v
          )
   where
    inRange n = n >= toInteger (minBound :: Int) && n <= toInteger (maxBound :: Int)
instance Contract Double where
  contract =
    scalar (ScalarInfo "Prelude.Double" []) $
      Just $
        C.primitive SNumber Number (\case Number d -> Right d; Integer n -> Right (fromInteger n); v -> C.mismatch "a number" v)
instance Contract () where contract = Unit
instance Contract a => Contract [a] where contract = List contract
instance Contract a => Contract (Maybe a) where contract = Optional contract
instance (Contract a, Contract b) => Contract (a, b) where
  contract = documented "A pair" $ Record (Constructor "(,)" PositionalFields) ((,) <$> required "_1" "" fst <*> required "_2" "" snd)
instance (Contract a, Contract b, Contract c) => Contract (a, b, c) where
  contract = documented "A triple" $ Record (Constructor "(,,)" PositionalFields) ((,,) <$> required "_1" "" (\(a, _, _) -> a) <*> required "_2" "" (\(_, b, _) -> b) <*> required "_3" "" (\(_, _, c) -> c))

-- | Derive from Generic while preserving nominal/applied identity, constructor
-- style and field order. Model compilation alone decides whether a positional
-- wrapper is transparent. The resulting contract tree must be finite.
genericContract :: forall a. (Generic a, Typeable a, GContract (Rep a)) => Structure a
genericContract = nominal (mapStructure to from (gcontract @(Rep a)))

-- | Constraint supporting generic derivation. Use genericContract rather than
-- depending on the representation traversal itself. Trees must be finite;
-- arbitrary recursive contracts are not supported by discovery or compilation.
class GContract (f :: Type -> Type) where
  -- | Describe a datatype representation without a nominal outer wrapper.
  gcontract :: Structure (f p)

instance GContract f => GContract (M1 D d f) where
  gcontract = mapStructure M1 unM1 (gcontract @f)
instance (GHC.Generics.Constructor c, GFields f) => GContract (M1 C c f) where
  gcontract = Record (genericConstructor @c) (M1 <$> lmapObject unM1 (snd (gfields @f 1)))
instance (GCases f, GCases g) => GContract (f :+: g) where
  gcontract = Sum (gcases @(f :+: g))
instance GContract V1 where gcontract = Sum []

-- | Generic sum traversal retaining constructor order and typed payloads.
class GCases (f :: Type -> Type) where
  -- | Describe the cases of a generic sum.
  gcases :: [Case (f p)]

instance GCases V1 where gcases = []
instance (GCases f, GCases g) => GCases (f :+: g) where
  gcases =
    map (inject L1 (\case L1 x -> Just x; _ -> Nothing)) (gcases @f)
      <> map (inject R1 (\case R1 x -> Just x; _ -> Nothing)) (gcases @g)
   where
    inject wrap unwrap (Case con d match construct fs) =
      Case con d (\y -> unwrap y >>= match) (wrap . construct) fs
instance (GHC.Generics.Constructor c, GFields f) => GCases (M1 C c f) where
  gcases = [Case (genericConstructor @c) Nothing (Just . unM1) M1 (snd (gfields @f 1))]

genericConstructor :: forall (c :: Meta). GHC.Generics.Constructor c => Constructor
genericConstructor =
  Constructor
    (T.pack (conName (undefined :: M1 C c U1 ())))
    (if conIsRecord (undefined :: M1 C c U1 ()) then RecordFields else PositionalFields)

-- | Generic field traversal retaining selector names and declaration order.
class GFields (f :: Type -> Type) where
  -- | Number positional fields from the supplied index, returning the next index.
  gfields :: Int -> (Int, Fields (f p) (f p))

instance GFields U1 where gfields n = (n, pure U1)
instance (GFields f, GFields g) => GFields (f :*: g) where
  gfields n =
    let (n1, a) = gfields @f n; (n2, b) = gfields @g n1
     in (n2, (:*:) <$> lmapObject (\(x :*: _) -> x) a <*> lmapObject (\(_ :*: y) -> y) b)
instance (Selector s, Contract a) => GFields (M1 S s (K1 i a)) where
  gfields n = (n + 1, M1 . K1 <$> Field name Nothing contract (unK1 . unM1))
   where
    selector = selName (undefined :: M1 S s (K1 i a) ())
    name = if null selector then "_" <> T.pack (show n) else T.pack selector

-- | An enumeration value with its model-facing label and optional description.
data Option a = Option
  { value :: a
  , label :: Text
  , doc :: Maybe Text
  }

-- | A documented enumeration in declaration order (lowest to highest for scores).
data OptionSet a = OptionSet
  { doc :: Maybe Text
  , options :: [Option a]
  -- ^ In order. For a score, this is the level order, lowest first.
  }

-- | Types whose values are a fixed, ordered list of described options. Jev's
-- @choice@ and @score@ need one.
class Options a where
  options :: OptionSet a
  default options :: (Generic a, GEnum (Rep a), Show a) => OptionSet a
  options = OptionSet Nothing [Option v (showLabel v) Nothing | v <- map to (genum @(Rep a))]

-- | One option, labelled by 'show' (the constructor's name, for an enumeration).
option :: Show a => a -> Text -> Option a
option v d = Option v (showLabel v) (nonEmpty d)

-- | Options with a description of the whole set.
documentedOptions :: Text -> [Option a] -> OptionSet a
documentedOptions d = OptionSet (nonEmpty d)

-- | An option's label: what the model sees and answers with. For an
-- enumeration, 'show' gives the constructor's name.
showLabel :: Show a => a -> Text
showLabel = T.pack . show

-- | Describe an Options type as a sum of documented nullary cases.
enumeration :: forall a. (Options a, Eq a) => Structure a
enumeration =
  documented (maybe "" id set.doc) $
    Sum
      [ Case (Constructor o.label PositionalFields) o.doc (\a -> if a == o.value then Just () else Nothing) (const o.value) (pure ())
      | o <- set.options
      ]
 where
  set = options @a

-- | Constraint supporting generic Options derivation.
class GEnum (f :: Type -> Type) where
  -- | Enumerate nullary constructors in declaration order.
  genum :: [f p]

instance GEnum f => GEnum (M1 D d f) where
  genum = map M1 genum

instance (GEnum f, GEnum g) => GEnum (f :+: g) where
  genum = map L1 genum <> map R1 genum

instance GEnum (M1 C c U1) where
  genum = [M1 U1]