packages feed

jev 0.1.0.0 → 0.1.1.0

raw patch · 6 files changed

+72/−7 lines, 6 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Jev.Types: renderJevError :: JevError -> Text

Files

CHANGELOG.md view
@@ -1,5 +1,16 @@ # Changelog +## 0.1.1.0++- Accept probabilities rounded to two decimals, as returned by OpenRouter.+  Distribution sums now tolerate `0.005` per returned probability, the selected+  Choice may trail another option by a rounded tie (`0.01`), and Score checks+  allow for rounded probabilities. Values are still not renormalized; clearly+  invalid distributions are still rejected.+- Add `renderJevError`, a log-friendly rendering of `JevError` with the+  constructor, status code, request ID, and message or truncated body. Unlike+  `show`, it never includes response headers such as `Set-Cookie`.+ ## 0.1.0.0  - Enforce `timeoutMicros` across the complete HTTP operation, including reading
README.md view
@@ -10,7 +10,10 @@ bounds currently target that tested environment; other compiler versions have not been verified. You can use Cabal directly; Nix is optional for consumers. -To consume a local checkout, place it beside your application and use this+Jev is published on [Hackage](https://hackage.haskell.org/package/jev): add+`jev` to your `build-depends`, as in the example below, and run `cabal update`.++To instead consume a local checkout, place it beside your application and use this `cabal.project` in the application directory:  ```cabal@@ -31,7 +34,7 @@  executable jev-demo     main-is: Main.hs-    build-depends: base >= 4.20 && < 4.21, text >= 2.1 && < 2.2, jev == 0.1.0.0+    build-depends: base >= 4.20 && < 4.21, text >= 2.1 && < 2.2, jev ^>= 0.1.1.0     default-language: GHC2021     default-extensions: OverloadedStrings, OverloadedRecordDot ```@@ -158,7 +161,7 @@  Question construction is pure; validation happens before any HTTP request. An entirely `pure` question or empty traversal contains no questions and is rejected. Choice accepts 1–255 options with unique wire labels; Score accepts 2–10 levels. No typeclass instances are required for Choice domain values. -Results expose constructors for pattern matching. Choice distributions pair domain values with probabilities in the supplied option order. Score distributions and legends use `IntMap`; supplied sparse distributions are preserved. The decoder checks finite probabilities in [0,1], distribution sums within `0.001` of one, a maximal selected Choice probability within `0.001`, and a Score within `0.001 * numberOfLevels` of its probability-weighted value. Legends must cover every returned probability index. Values are never renormalized or filled in. Confidence is retained as a separate provider measure in [0,1], not recomputed from the distribution. Public constructors allow manually created values that bypass these checks.+Results expose constructors for pattern matching. Choice distributions pair domain values with probabilities in the supplied option order. Score distributions and legends use `IntMap`; supplied sparse distributions are preserved. The decoder checks finite probabilities in [0,1] and tolerates providers that round them to two decimals, as OpenRouter does: distribution sums within `0.001 + 0.005 * numberOfReturnedProbabilities` of one, a maximal selected Choice probability within `0.011` (a rounded tie), and a Score within `0.001 * numberOfLevels + 0.005 * (1 + sumOfReturnedIndices)` of its probability-weighted value. Legends must cover every returned probability index. Values are never renormalized or filled in. Confidence is retained as a separate provider measure in [0,1], not recomputed from the distribution. Public constructors allow manually created values that bypass these checks.  `Response`, `Choice`, `Option`, `JsonOption`, and `NoulCriteria` have `Functor` instances. Mapping a `Choice` transforms both the selection and the values in its@@ -202,6 +205,10 @@ - `ResponseDecodeError metadata message`: a 2xx response could not be decoded;   the same metadata is retained, including the body ID when readable or header ID. - `DecodeError message`: decoding a fixture through `decodeResponse` failed.++`renderJevError err` produces log-friendly `Text` with the constructor, status code,+request ID, and message or body (truncated to 500 bytes). It never includes+response headers such as `Set-Cookie`, unlike `show err`, which prints them in full.  Asynchronous cancellation propagates normally. Exceptions from custom manager hooks or user functions mapped over questions are not generally converted to
jev.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               jev-version:            0.1.0.0+version:            0.1.1.0 synopsis: Typed decisions with Jev through TypeSafe and OpenRouter description: Typed, composable Jev questions with reusable HTTPS clients. homepage:           https://github.com/realbogart/jev
lib/Jev/Internal/Protocol.hs view
@@ -81,8 +81,12 @@ probabilityTolerance :: Double probabilityTolerance = 1e-3 +-- Maximum error of one probability rounded to two decimals, as OpenRouter returns them.+roundingTolerance :: Double+roundingTolerance = 5e-3+ distribution :: [Double] -> Parser ()-distribution values = unless (abs (sum values - 1) <= probabilityTolerance) (fail "Probabilities must sum to approximately one")+distribution values = unless (abs (sum values - 1) <= probabilityTolerance + roundingTolerance * fromIntegral (length values)) (fail "Probabilities must sum to approximately one")  choiceQuestion :: Value -> [JsonOption a] -> Question (Choice a) choiceQuestion instructions options = primitive "choice" instructions (Just criteria) check $ \o -> do@@ -100,7 +104,7 @@       options   distribution (map snd probabilities)   selectedProbability <- maybe (fail "Missing selected probability") pure (Map.lookup label values)-  unless (all (\p -> p <= selectedProbability + probabilityTolerance) (Map.elems values)) (fail "Selected choice is not a highest-probability option")+  unless (all (\p -> p <= selectedProbability + probabilityTolerance + 2 * roundingTolerance) (Map.elems values)) (fail "Selected choice is not a highest-probability option")   pure (Choice selected confidence probabilities)   where     mapping = Map.fromList [(key, a) | JsonOption a key _ <- options]@@ -122,7 +126,9 @@   distribution (IM.elems probabilities)   unless (all (`IM.member` legend) (IM.keys probabilities)) (fail "Score legend is missing probability indices")   let expected = sum [fromIntegral i * p | (i, p) <- IM.toList probabilities]-  unless (abs (score - expected) <= probabilityTolerance * fromIntegral (length levels)) (fail "Score does not match its probability-weighted rubric")+      -- The score and each index-weighted probability may carry a rounding error.+      roundingError = roundingTolerance * (1 + fromIntegral (sum (IM.keys probabilities)))+  unless (abs (score - expected) <= probabilityTolerance * fromIntegral (length levels) + roundingError) (fail "Score does not match its probability-weighted rubric")   pure (Score score confidence probabilities legend)   where     check _ = do
lib/Jev/Types.hs view
@@ -18,13 +18,17 @@     ResponseMetadata (..),     TransportFailure (..),     JevError (..),+    renderJevError,   ) where  import Data.Aeson (Value) import Data.ByteString.Lazy qualified as LBS import Data.IntMap.Strict (IntMap)+import Data.Maybe (fromMaybe) import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8Lenient) import Network.HTTP.Types.Header (ResponseHeaders)  -- | The gateway used for validation and the default endpoint.@@ -205,3 +209,19 @@   | -- | A successful HTTP response contained an invalid answer.     ResponseDecodeError ResponseMetadata Text   deriving (Eq, Show)++-- | Log-friendly description with the constructor, status code, request ID, and+-- message or body (truncated to 500 bytes). Never includes response headers.+-- Error bodies are server-controlled and may still contain sensitive data.+renderJevError :: JevError -> Text+renderJevError err = case err of+  ValidationError message -> "ValidationError: " <> message+  TransportError failure -> "TransportError: " <> T.pack (show failure)+  HttpError metadata body -> "HttpError" <> context metadata <> ": " <> truncated body+  DecodeError message -> "DecodeError: " <> message+  ResponseDecodeError metadata message -> "ResponseDecodeError" <> context metadata <> ": " <> message+  where+    context metadata = " (status " <> T.pack (show metadata.statusCode) <> ", request ID " <> fromMaybe "unknown" metadata.requestId <> ")"+    truncated body+      | LBS.length body > 500 = decodeUtf8Lenient (LBS.toStrict (LBS.take 500 body)) <> "... (truncated)"+      | otherwise = decodeUtf8Lenient (LBS.toStrict body)
test/JevSpec.hs view
@@ -212,6 +212,27 @@       decodeFixture rubric (replace "score" (Number 0) scoreAnswer) `shouldSatisfy` isDecodeError       decodeFixture rubric (replace "legend" (object []) scoreAnswer) `shouldSatisfy` isDecodeError       fmap (\r -> r.answers.probabilities) (decodeFixture rubric scoreAnswer) `shouldBe` Right (IM.fromList [(1, 0.6), (2, 0.4)])+    it "accepts distributions rounded to two decimals and rejects clearly invalid ones" $ do+      let letters n = choice "Pick" [Option c (T.singleton c) Nothing | c <- take n ['a' ..]]+          rounded selected values = object ["type" .= ("choice" :: Text), "choice" .= selected, "confidence" .= (0.5 :: Double), "probabilities" .= object values]+          decodeFixture question value = do+            prepared <- prepareRequest OpenRouter "model" (String "State") question+            fmap (\r -> r.answers.choice) (decodeResponse prepared (encode (envelope (object ["q0" .= value]))))+      decodeFixture (letters 3) (rounded ("a" :: Text) ["a" .= (0.33 :: Double), "b" .= (0.33 :: Double), "c" .= (0.33 :: Double)]) `shouldBe` Right 'a'+      decodeFixture (letters 4) (rounded ("a" :: Text) ["a" .= (0.26 :: Double), "b" .= (0.25 :: Double), "c" .= (0.25 :: Double), "d" .= (0.25 :: Double)]) `shouldBe` Right 'a'+      decodeFixture (letters 3) (rounded ("a" :: Text) ["a" .= (0.36 :: Double), "b" .= (0.37 :: Double), "c" .= (0.27 :: Double)]) `shouldBe` Right 'a'+      decodeFixture route (rounded ("billing" :: Text) ["billing" .= (0.85 :: Double), "technical" .= (0.05 :: Double)]) `shouldSatisfy` isDecodeError+      decodeFixture route (rounded ("billing" :: Text) ["billing" .= (0.9 :: Double), "technical" .= (0.3 :: Double)]) `shouldSatisfy` isDecodeError+      decodeFixture (letters 3) (rounded ("a" :: Text) ["a" .= (0.34 :: Double), "b" .= (0.36 :: Double), "c" .= (0.3 :: Double)]) `shouldSatisfy` isDecodeError+  describe "error rendering" $ do+    it "includes status, request ID, and message but never headers" $ do+      let metadata = ResponseMetadata 503 [("Set-Cookie", "session=secret-cookie"), ("x-typesafe-request-id", "req-1")] (Just "req-1")+          httpText = renderJevError (HttpError metadata "upstream unavailable")+          decodeText = renderJevError (ResponseDecodeError metadata "Error in $.q0: Probabilities must sum to approximately one")+      httpText `shouldBe` "HttpError (status 503, request ID req-1): upstream unavailable"+      decodeText `shouldBe` "ResponseDecodeError (status 503, request ID req-1): Error in $.q0: Probabilities must sum to approximately one"+      mapM_ (\rendered -> rendered `shouldSatisfy` (not . T.isInfixOf "secret-cookie")) [httpText, decodeText]+      T.length (renderJevError (HttpError metadata (LBS.replicate 10000 120))) `shouldSatisfy` (< 600)     it "retains retry headers and request IDs on HTTP and decoding failures" $ do       let app status body _ respond = respond (responseLBS status [("Retry-After", "7"), ("x-typesafe-request-id", "header-id")] body)       failed <- withServer (app status429 "limited") $ \client -> decide client "State" (noul "Yes?")