Skip to content

Commit d5ab2fc

Browse files
authored
feat: validate cached futility-rejection replay against a state seal (#15)
Rejection-classification logic (normaliseDiagnostic, worldChanging, cacheableRejection, unchangedState, ...) moves out of Futility.hs into its own Futility.Rejection module. Cached rejection replay is now checked against VerifyMemo.currentSeal; a stale seal invalidates the world instead of being replayed unconditionally.
1 parent 6ecce55 commit d5ab2fc

7 files changed

Lines changed: 417 additions & 180 deletions

File tree

siza-client/siza-client.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ library
5959
Siza.Agent.RepairLocate
6060
Siza.Agent.RepairTiers
6161
Siza.Agent.Futility
62+
Siza.Agent.Futility.Rejection
6263
Siza.Agent.Streak
6364
Siza.Agent.Chat.Verify
6465
Siza.Agent.Check

siza-client/src/Siza/Agent/Futility.hs

Lines changed: 114 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -17,58 +17,66 @@ module Siza.Agent.Futility (
1717
unchangedState,
1818
) where
1919

20-
import Data.Aeson (Value (..), encode, object, toJSON, (.=))
20+
import Data.Aeson (Value (..), encode, object, (.=))
2121
import qualified Data.Aeson.Key as K
2222
import qualified Data.Aeson.KeyMap as KM
2323
import qualified Data.ByteString.Lazy as LBS
24-
import Data.Char (isDigit)
2524
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
2625
import Data.Map.Strict (Map)
2726
import qualified Data.Map.Strict as Map
2827
import Data.Maybe (fromMaybe, listToMaybe)
29-
import Data.Set (Set)
3028
import qualified Data.Set as Set
3129
import Data.Text (Text)
3230
import qualified Data.Text as T
3331
import qualified Data.Text.Encoding as TE
3432
import qualified Data.Text.Encoding.Error as TEE
3533

36-
import Sabela.AI.SelfHeal (sourceDelta)
3734
import Sabela.AI.Types (ToolOutcome (..))
3835
import Sabela.LLM.Ollama.Client (ToolCall (..))
39-
40-
{- | What a diagnostic class has already cost: how many calls it answered, the
41-
submitted sources it answered them for, and the most recent of those.
42-
-}
43-
data RejectionRun = RejectionRun
44-
{ rrCount :: !Int
45-
, rrSources :: !(Set Text)
46-
, rrLast :: !Text
47-
}
36+
import Siza.Agent.Futility.Rejection (
37+
CachedRejection (..),
38+
RejectionRun (..),
39+
cacheableRejection,
40+
cachedFact,
41+
completeWrite,
42+
markUnchanged,
43+
normaliseDiagnostic,
44+
settledNoMutation,
45+
unchangedState,
46+
worldChanging,
47+
)
48+
import Siza.Agent.VerifyMemo (Seal, currentSeal)
4849

4950
data GuardState = GuardState
5051
{ gsCalls :: !(Map (Text, Text) Text)
5152
, gsRuns :: !(Map Text RejectionRun)
53+
, gsEpochRuns :: !(Map Text RejectionRun)
54+
, gsCached :: !(Map (Text, Text) CachedRejection)
55+
, gsEpoch :: !Int
5256
}
5357

5458
newtype FutilityGuard = FutilityGuard (IORef GuardState)
5559

5660
newFutilityGuard :: IO FutilityGuard
57-
newFutilityGuard = FutilityGuard <$> newIORef (GuardState Map.empty Map.empty)
61+
newFutilityGuard =
62+
FutilityGuard
63+
<$> newIORef
64+
GuardState
65+
{ gsCalls = Map.empty
66+
, gsRuns = Map.empty
67+
, gsEpochRuns = Map.empty
68+
, gsCached = Map.empty
69+
, gsEpoch = 0
70+
}
5871

5972
futilityNote :: Text
6073
futilityNote =
61-
"This call was byte-identical to an earlier call and failed with the \
62-
\identical error. Re-sending or re-phrasing the same payload will not \
63-
\change the outcome - the payload is not the fault. Change approach: \
64-
\check kernel_status / list_cells, use a different tool, or take a \
65-
\smaller step."
74+
"This call's name and arguments match an earlier call, and the recorded \
75+
\error is identical."
6676

6777
sourceFaultNote :: Text
6878
sourceFaultNote =
69-
"This exact source was rejected before with the identical diagnostic. It \
70-
\is deterministic: the fault is in the source, not the kernel or the \
71-
\environment. Read the diagnostic above and change the source it names."
79+
"This exact source was rejected before with the identical compiler diagnostic."
7280

7381
noteFor :: Either Text ToolOutcome -> Text
7482
noteFor out
@@ -88,7 +96,48 @@ guardDispatch ::
8896
ToolCall ->
8997
IO (Either Text ToolOutcome)
9098
guardDispatch (FutilityGuard ref) dispatch call = do
91-
out <- dispatch call
99+
reused <- validatedCache ref dispatch call
100+
maybe fresh pure reused
101+
where
102+
fresh = do
103+
out <- dispatch call
104+
seal <- rejectionSeal dispatch call out
105+
_ <- atomicModifyIORef' ref (afterDispatch call out seal)
106+
observe False ref call out
107+
108+
validatedCache ::
109+
IORef GuardState ->
110+
(ToolCall -> IO (Either Text ToolOutcome)) ->
111+
ToolCall ->
112+
IO (Maybe (Either Text ToolOutcome))
113+
validatedCache ref dispatch call = do
114+
cached <- Map.lookup (callKey call) . gsCached <$> readIORef ref
115+
case cached of
116+
Just c | completeWrite call -> do
117+
seal <- currentSeal dispatch
118+
if seal == Just (cachedSeal c)
119+
then Just <$> observe True ref call (Right (cachedOutcome c))
120+
else do
121+
atomicModifyIORef' ref (\s -> (invalidateWorld s, ()))
122+
pure Nothing
123+
_ -> pure Nothing
124+
125+
rejectionSeal ::
126+
(ToolCall -> IO (Either Text ToolOutcome)) ->
127+
ToolCall ->
128+
Either Text ToolOutcome ->
129+
IO (Maybe Seal)
130+
rejectionSeal dispatch call out
131+
| completeWrite call && cacheableRejection out = currentSeal dispatch
132+
| otherwise = pure Nothing
133+
134+
observe ::
135+
Bool ->
136+
IORef GuardState ->
137+
ToolCall ->
138+
Either Text ToolOutcome ->
139+
IO (Either Text ToolOutcome)
140+
observe reused ref call out = do
92141
let key = callKey call
93142
src = submittedSource call
94143
mClass = diagnosticClass out
@@ -103,7 +152,43 @@ guardDispatch (FutilityGuard ref) dispatch call = do
103152
annotated
104153
| prevFt == Just ft = annotate (noteFor out) marked
105154
| otherwise = marked
106-
pure annotated
155+
epoch <- gsEpoch <$> readIORef ref
156+
pure (if reused then cachedFact epoch annotated else annotated)
157+
158+
afterDispatch ::
159+
ToolCall ->
160+
Either Text ToolOutcome ->
161+
Maybe Seal ->
162+
GuardState ->
163+
(GuardState, Int)
164+
afterDispatch call out seal s
165+
| worldChanging call && not (settledNoMutation out) =
166+
let s' = invalidateWorld s
167+
in (s', gsEpoch s')
168+
| completeWrite call
169+
, cacheableRejection out
170+
, Right rejected <- out =
171+
case seal of
172+
Just observed ->
173+
( s
174+
{ gsCached =
175+
Map.insert
176+
(callKey call)
177+
(CachedRejection rejected observed)
178+
(gsCached s)
179+
}
180+
, gsEpoch s
181+
)
182+
Nothing -> (s, gsEpoch s)
183+
| otherwise = (s, gsEpoch s)
184+
185+
invalidateWorld :: GuardState -> GuardState
186+
invalidateWorld s =
187+
s
188+
{ gsEpochRuns = Map.empty
189+
, gsCached = Map.empty
190+
, gsEpoch = gsEpoch s + 1
191+
}
107192

108193
forgetCall :: (Text, Text) -> GuardState -> GuardState
109194
forgetCall key s = s{gsCalls = Map.delete key (gsCalls s)}
@@ -122,12 +207,13 @@ record ::
122207
record key ft mClass src s = (s', (Map.lookup key (gsCalls s), prevRun))
123208
where
124209
s' =
125-
GuardState
210+
s
126211
{ gsCalls = Map.insert key ft (gsCalls s)
127-
, gsRuns = maybe (gsRuns s) bump mClass
212+
, gsRuns = maybe (gsRuns s) (bump (gsRuns s)) mClass
213+
, gsEpochRuns = maybe (gsEpochRuns s) (bump (gsEpochRuns s)) mClass
128214
}
129-
prevRun = flip Map.lookup (gsRuns s) =<< mClass
130-
bump cls = Map.insert cls (extend prevRun) (gsRuns s)
215+
prevRun = flip Map.lookup (gsEpochRuns s) =<< mClass
216+
bump runs cls = Map.insert cls (extend (Map.lookup cls runs)) runs
131217
extend Nothing = RejectionRun 1 (Set.singleton src) src
132218
extend (Just r) =
133219
RejectionRun (rrCount r + 1) (Set.insert src (rrSources r)) src
@@ -139,26 +225,6 @@ rejectionRepeats :: FutilityGuard -> IO (Map Text Int)
139225
rejectionRepeats (FutilityGuard ref) =
140226
Map.map (subtract 1 . rrCount) . gsRuns <$> readIORef ref
141227

142-
{- | A diagnostic with its @\<interactive\>@ positions erased. Two rejections
143-
that differ only in where the session happened to place the candidate are the
144-
same diagnostic, and the model cannot act on the difference.
145-
-}
146-
normaliseDiagnostic :: Text -> Text
147-
normaliseDiagnostic t = case T.breakOn interactiveMarker t of
148-
(_, rest) | T.null rest -> t
149-
(pre, rest) ->
150-
let body = T.drop (T.length interactiveMarker) rest
151-
(pos, after) = T.span positionChar body
152-
in pre
153-
<> interactiveMarker
154-
<> (if T.null pos then "" else "L:C")
155-
<> normaliseDiagnostic after
156-
where
157-
positionChar c = isDigit c || c == ':' || c == '-'
158-
159-
interactiveMarker :: Text
160-
interactiveMarker = "<interactive>:"
161-
162228
{- | The diagnostic class a deterministic rejection belongs to. Only a
163229
rejection carrying a diagnostic has one: an outcome with no diagnostic gives
164230
the guard nothing to compare.
@@ -171,74 +237,6 @@ diagnosticClass out@(Right (ToolErr (Object o)))
171237
Just (normaliseDiagnostic d)
172238
diagnosticClass _ = Nothing
173239

174-
{- | Say that this diagnostic is the one an earlier call already produced, and
175-
over how many distinct sources. Both numbers are counted here; nothing is
176-
claimed about where the cause is.
177-
-}
178-
markUnchanged ::
179-
Text -> Either Text ToolOutcome -> RejectionRun -> Either Text ToolOutcome
180-
markUnchanged src (Right (ToolErr (Object o))) prev =
181-
Right (ToolErr (Object (KM.insert (K.fromText "unchanged") detail marked)))
182-
where
183-
marked = KM.insert (K.fromText "state") (String unchangedState) o
184-
detail =
185-
object
186-
( [ "priorCalls" .= rrCount prev
187-
, "distinctSources" .= Set.size sources
188-
, "sourceChanged" .= changed
189-
, "note" .= unchangedNote (rrCount prev) (Set.size sources)
190-
]
191-
<> changedLinesPairs
192-
)
193-
sources = Set.filter (not . T.null) (Set.insert src (rrSources prev))
194-
changed = src /= rrLast prev
195-
(removed, added) = sourceDelta (rrLast prev) src
196-
dropped =
197-
length removed
198-
+ length added
199-
- length (take deltaLines removed)
200-
- length (take deltaLines added)
201-
changedLinesPairs
202-
| not changed = []
203-
| otherwise =
204-
[ "changedLines"
205-
.= object
206-
( [ "removed" .= toJSON (take deltaLines removed)
207-
, "added" .= toJSON (take deltaLines added)
208-
]
209-
<> ["furtherLines" .= dropped | dropped > 0]
210-
)
211-
]
212-
markUnchanged _ out _ = out
213-
214-
-- | How many changed lines either side of the delta is worth carrying.
215-
deltaLines :: Int
216-
deltaLines = 12
217-
218-
unchangedState :: Text
219-
unchangedState = "unchanged"
220-
221-
{- | Only what was counted: how many calls this diagnostic answered, and how
222-
many different non-empty sources those calls submitted.
223-
-}
224-
unchangedNote :: Int -> Int -> Text
225-
unchangedNote priorCalls distinct =
226-
"This diagnostic is identical, ignoring <interactive> line and column \
227-
\numbers, to the one "
228-
<> tshow priorCalls
229-
<> " earlier call(s) in this session produced."
230-
<> sourceSentence
231-
where
232-
sourceSentence
233-
| distinct <= 0 = ""
234-
| otherwise =
235-
" Counting this call, "
236-
<> tshow distinct
237-
<> " distinct submitted source(s) have produced it."
238-
239-
tshow :: (Show a) => a -> Text
240-
tshow = T.pack . show
241-
242240
callKey :: ToolCall -> (Text, Text)
243241
callKey (ToolCall n a) = (n, encodeText a)
244242

0 commit comments

Comments
 (0)