Skip to content

Commit 22557b9

Browse files
committed
feat: Add hole repair for siza chat
1 parent 7f0f183 commit 22557b9

12 files changed

Lines changed: 602 additions & 13 deletions

File tree

‎eval/neuro-symbolic/test/Test/HoleFitSpec.hs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Data.Text (Text)
66
import Test.Hspec
77

88
import Eval.HoleFit (
9+
arityFromError,
910
dropAnnotation,
1011
droppableAnnotation,
1112
goalFromError,
@@ -80,6 +81,29 @@ spec = describe "Eval.HoleFit (substitute-and-verify core)" $ do
8081
it "drops the outer annotation, keeping an inner type signature" $
8182
dropAnnotation "Maybe" "answer = (6 * 7 :: Int) :: Maybe"
8283
`shouldBe` "answer = (6 * 7 :: Int)"
84+
{- The trigger for head-anchored application synthesis. Strings are the real
85+
GHC 9.12 diagnostics gemma4 produced on the revenueTotal bench task. -}
86+
describe "arityFromError (misapplication trigger)" $ do
87+
it "reads the expected FUNCTION type of a misapplication" $
88+
arityFromError
89+
"cell 2, line 5: Couldn't match expected type: D.DataFrame -> D.Expr Double\n with actual type: D.Expr Double"
90+
`shouldBe` Just "D.DataFrame -> D.Expr Double"
91+
it "fires when the expected type ends in a type variable" $
92+
arityFromError "Couldn't match expected type: D.DataFrame -> t"
93+
`shouldBe` Just "D.DataFrame -> t"
94+
it "does NOT fire on a Text/String coercion (a different repair class)" $
95+
arityFromError "Couldn't match type `T.Text' with `[Char]'"
96+
`shouldBe` Nothing
97+
it "does NOT fire on a non-function expected type (nothing to permute)" $
98+
arityFromError "Couldn't match expected type `DataFrame'"
99+
`shouldBe` Nothing
100+
it "does NOT fire on a not-in-scope error (the hole-fit tier owns that)" $
101+
arityFromError "Variable not in scope: foldrr :: [Int] -> Int"
102+
`shouldBe` Nothing
103+
it "is Nothing for an unrelated error" $
104+
arityFromError "Could not find module `Data.DataFrame'." `shouldBe` Nothing
105+
106+
describe "dropAnnotation (paren + whitespace preservation)" $ do
83107
it "drops a parenthesised annotation without eating the closing paren" $
84108
dropAnnotation "Int" "y = (foo :: Int) + 1" `shouldBe` "y = (foo) + 1"
85109
it "preserves the whitespace of a literal after the annotation" $

‎sabela.cabal‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ library sabela-contract
4444
Sabela.AI.CellResult,
4545
Sabela.AI.KernelState,
4646
Sabela.AI.Provenance,
47+
Sabela.AI.RepairTrace,
4748
Sabela.AI.Output,
4849
Sabela.AI.Grammar,
4950
Sabela.AI.Grammar.Synth,
@@ -58,6 +59,7 @@ library sabela-contract
5859
Sabela.AI.Repair,
5960
Sabela.AI.Salvage,
6061
Sabela.AI.Similarity,
62+
Sabela.AI.Spine,
6163
Sabela.AI.Capabilities.ToolName,
6264
Sabela.LLM.Ollama.Client,
6365
Sabela.LLM.Ollama,
@@ -132,6 +134,7 @@ library
132134
Sabela.AI.Orchestrator,
133135
Sabela.AI.Orchestrator.Compact,
134136
Sabela.AI.Orchestrator.Loop,
137+
Sabela.AI.Capabilities.Edit.Assemble,
135138
Sabela.AI.Orchestrator.Loop.Accept,
136139
Sabela.AI.Orchestrator.Loop.Dispatch,
137140
Sabela.AI.Orchestrator.Loop.Usage,
@@ -284,6 +287,7 @@ test-suite sabela-test
284287
Test.CellDefinesSpec,
285288
Test.CheckTypeStructSpec,
286289
Test.DiagnoseSpec,
290+
Test.SpineSpec,
287291
Test.ModuleResolveSpec,
288292
Test.DiscoverToolSpec,
289293
Test.DepRepairSpec,
@@ -354,6 +358,7 @@ test-suite sabela-test
354358
Test.AwaitIdleSpec,
355359
Test.MarkerSpec,
356360
Test.RenderSpec,
361+
Test.RepairTraceWireSpec,
357362
Test.ReplProjectSpec,
358363
Test.ExportGoldenSpec,
359364
Test.ConfigurableTimeoutSpec,

‎src-contract/Sabela/AI/HoleRepair.hs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ for a fit in source. Shared by the notebook repair path and the eval harness.
66
-}
77
module Sabela.AI.HoleRepair (
88
goalFromError,
9+
arityFromError,
910
holeTypeFromDiagnostic,
1011
droppableAnnotation,
1112
dropAnnotation,
@@ -104,6 +105,21 @@ goalFromError err = do
104105
(w : _) -> Just w
105106
[] -> Nothing
106107

108+
{- | The expected FUNCTION type a misapplication names, from GHC's
109+
@"Couldn't match expected type: A -> B"@. A function-shaped expectation means the
110+
head was applied to the wrong arguments — an order/arity slip permuting can fix.
111+
112+
'Nothing' for a plain coercion (@Couldn't match type 'T.Text' with '[Char]'@), a
113+
non-function expectation (nothing to permute), or a not-in-scope error, which the
114+
hole-fit tier owns. The trigger is deliberately narrow: it must never claim an
115+
error class this repair cannot actually fix.
116+
-}
117+
arityFromError :: Text -> Maybe Text
118+
arityFromError err = do
119+
rest <- afterInfix "Couldn't match expected type:" err
120+
let ty = T.strip (T.takeWhile (/= '\n') rest)
121+
if T.null ty || not ("->" `T.isInfixOf` ty) then Nothing else Just ty
122+
107123
-- | Plain (non-refinement) hole-fit names to try, over the shared 'parseHoleFits'.
108124
holeFitNames :: Text -> [Text]
109125
holeFitNames blob = [hfWrite f | f <- parseHoleFits blob, not (hfRefined f)]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
3+
{- | The repair-tier counter log: one append-only JSONL record per speculative
4+
repair round, so a measured null result can be read.
5+
6+
Without it, "the tier did not help" and "the tier never fired" look identical —
7+
which is how an earlier hole-fit run was scored inert when its trigger simply
8+
never matched. The counters separate the two: no candidates means a TRIGGER bug,
9+
candidates but no winner means a SELECTOR bug, and a winner that still fails the
10+
task means the idea is genuinely dead.
11+
12+
Server-side and best-effort: the eval harness sends the server's stdout to
13+
/dev/null but keeps its work dir, so a file is the only channel that survives a
14+
bench run. A write failure can never fail a repair.
15+
-}
16+
module Sabela.AI.RepairTrace (
17+
RepairEvent (..),
18+
repairEventJSON,
19+
repairTracePath,
20+
recordRepair,
21+
) where
22+
23+
import Control.Exception (SomeException, try)
24+
import Control.Monad (void)
25+
import Data.Aeson (Value, encode, object, (.=))
26+
import qualified Data.Aeson.Key as K
27+
import qualified Data.ByteString.Lazy.Char8 as LBS
28+
import Data.Text (Text)
29+
import System.Directory (createDirectoryIfMissing)
30+
import System.FilePath (takeDirectory, (</>))
31+
32+
{- | One speculative-repair round. 'reCounts' is per candidate source, so a tier
33+
that produced nothing is distinguishable from one whose candidates all failed the
34+
type check. 'reWinner' names the source whose candidate was selected, if any.
35+
-}
36+
data RepairEvent = RepairEvent
37+
{ reCellId :: Int
38+
, reCounts :: [(Text, Int)]
39+
, reWinner :: Maybe Text
40+
}
41+
deriving (Eq, Show)
42+
43+
-- | The JSONL line shape, pinned by @Test.RepairTraceWireSpec@.
44+
repairEventJSON :: RepairEvent -> Value
45+
repairEventJSON ev =
46+
object
47+
[ "cellId" .= reCellId ev
48+
, "counts" .= object [K.fromText k .= v | (k, v) <- reCounts ev]
49+
, "winner" .= reWinner ev
50+
]
51+
52+
-- | @<workDir>/.sabela/repair-trace.jsonl@.
53+
repairTracePath :: FilePath -> FilePath
54+
repairTracePath workDir = workDir </> ".sabela" </> "repair-trace.jsonl"
55+
56+
{- | Append one record, creating the directory tree on demand. Best-effort: any
57+
IO failure is swallowed, mirroring 'Sabela.AI.Provenance.recordEvent', so tracing
58+
can never fail the repair it is measuring.
59+
-}
60+
recordRepair :: FilePath -> RepairEvent -> IO ()
61+
recordRepair workDir ev = void (try go :: IO (Either SomeException ()))
62+
where
63+
path = repairTracePath workDir
64+
go = do
65+
createDirectoryIfMissing True (takeDirectory path)
66+
appendFile path (LBS.unpack (encode (repairEventJSON ev)) ++ "\n")

‎src-contract/Sabela/AI/Spine.hs‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
3+
{- | The application spine of an expression: a head applied to arguments, with
4+
visible type applications kept aside. Backs arity repair, where a weak model
5+
over-applies a function (@D.col \@Double "revenue" df@ against @col :: Text ->
6+
Expr a@) and the fix is to drop the excess argument.
7+
8+
Lexical, not an AST: nothing in the tree parses an expression to a term or prints
9+
one back. So this reads only a plain application and declines everything else —
10+
a decline costs a missed repair, a misparse would corrupt the cell.
11+
-}
12+
module Sabela.AI.Spine (
13+
Spine (..),
14+
splitSpine,
15+
renderSpine,
16+
trimTo,
17+
spineArity,
18+
) where
19+
20+
import Data.Char (isAlpha, isAlphaNum)
21+
import Data.Text (Text)
22+
import qualified Data.Text as T
23+
24+
{- | A head applied to arguments. A parenthesised argument is kept whole (parens
25+
included) so it can be re-read on its own; type applications ride separately so a
26+
trim cannot drop them.
27+
-}
28+
data Spine = Spine
29+
{ spHead :: Text
30+
, spTypeApps :: [Text]
31+
, spArgs :: [Text]
32+
}
33+
deriving (Eq, Show)
34+
35+
-- | The number of value arguments the spine applies.
36+
spineArity :: Spine -> Int
37+
spineArity = length . spArgs
38+
39+
{- | Keep at most @n@ value arguments. The fix for an over-applied head; never
40+
grows a spine, so a wrong arity can only ever shorten it.
41+
-}
42+
trimTo :: Int -> Spine -> Spine
43+
trimTo n sp = sp{spArgs = take (max 0 n) (spArgs sp)}
44+
45+
-- | Re-render a spine: @head \@T arg1 arg2@.
46+
renderSpine :: Spine -> Text
47+
renderSpine sp = T.unwords (spHead sp : spTypeApps sp ++ spArgs sp)
48+
49+
{- | Read a plain application. 'Nothing' unless the whole expression is an
50+
identifier head applied to atoms, literals, or balanced parenthesised groups —
51+
an operator, lambda, literal head, or unbalanced text declines.
52+
-}
53+
splitSpine :: Text -> Maybe Spine
54+
splitSpine src = do
55+
toks <- spineTokens (T.strip src)
56+
case toks of
57+
[] -> Nothing
58+
(h : rest)
59+
| isIdentHead h ->
60+
Just
61+
Spine
62+
{ spHead = h
63+
, spTypeApps = filter isTypeApp rest
64+
, spArgs = filter (not . isTypeApp) rest
65+
}
66+
_ -> Nothing
67+
68+
-- | An application head: a (possibly qualified) identifier, never a literal.
69+
isIdentHead :: Text -> Bool
70+
isIdentHead t = case T.uncons t of
71+
Just (c, _) -> (isAlpha c || c == '_') && T.all isIdentChar t
72+
Nothing -> False
73+
where
74+
isIdentChar c = isAlphaNum c || c `elem` ("_.'" :: String)
75+
76+
isTypeApp :: Text -> Bool
77+
isTypeApp = T.isPrefixOf "@"
78+
79+
{- | Split into top-level tokens, keeping string literals and parenthesised
80+
groups whole. 'Nothing' on unbalanced parens or a token that is not an
81+
identifier, literal, type application, or group.
82+
-}
83+
spineTokens :: Text -> Maybe [Text]
84+
spineTokens = go [] . T.strip
85+
where
86+
go acc t
87+
| T.null t = Just (reverse acc)
88+
| otherwise = do
89+
(tok, rest) <- lexOne t
90+
go (tok : acc) (T.stripStart rest)
91+
92+
{- | One top-level token. Fails on an unbalanced group or a bare operator, which
93+
is how a non-application expression declines.
94+
-}
95+
lexOne :: Text -> Maybe (Text, Text)
96+
lexOne t = case T.uncons t of
97+
Nothing -> Nothing
98+
Just ('(', _) -> lexGroup t
99+
Just ('"', _) -> lexString t
100+
Just (c, _)
101+
| isAlpha c || c == '_' || c == '@' || isAlphaNum c ->
102+
let (tok, rest) = T.span plain t
103+
in if T.null tok then Nothing else Just (tok, rest)
104+
_ -> Nothing
105+
where
106+
plain c = isAlphaNum c || c `elem` ("_.'@[]" :: String)
107+
108+
-- | A balanced parenthesised group, parens kept, nested groups and strings honoured.
109+
lexGroup :: Text -> Maybe (Text, Text)
110+
lexGroup t = walk 0 0 False t
111+
where
112+
walk :: Int -> Int -> Bool -> Text -> Maybe (Text, Text)
113+
walk depth i inStr s = case T.uncons s of
114+
Nothing -> Nothing
115+
Just (c, cs)
116+
| inStr, c == '\\' -> skipEscape depth i cs
117+
| inStr, c == '"' -> walk depth (i + 1) False cs
118+
| inStr -> walk depth (i + 1) True cs
119+
| c == '"' -> walk depth (i + 1) True cs
120+
| c == '(' -> walk (depth + 1) (i + 1) False cs
121+
| c == ')' ->
122+
if depth == 1
123+
then Just (T.take (i + 1) t, T.drop (i + 1) t)
124+
else walk (depth - 1) (i + 1) False cs
125+
| otherwise -> walk depth (i + 1) inStr cs
126+
skipEscape depth i cs = case T.uncons cs of
127+
Nothing -> Nothing
128+
Just (_, cs') -> walk depth (i + 2) True cs'
129+
130+
-- | A string literal, quotes kept, escapes honoured.
131+
lexString :: Text -> Maybe (Text, Text)
132+
lexString t = walk 1 (T.drop 1 t)
133+
where
134+
walk i s = case T.uncons s of
135+
Nothing -> Nothing
136+
Just ('\\', cs) -> case T.uncons cs of
137+
Nothing -> Nothing
138+
Just (_, cs') -> walk (i + 2) cs'
139+
Just ('"', _) -> Just (T.take (i + 1) t, T.drop (i + 1) t)
140+
Just (_, cs) -> walk (i + 1) cs

0 commit comments

Comments
 (0)