ai-agent-diff-patch-0.0.1.0: src/AIAgent/DiffPatch/Unified/ProjectedContext/Algorithm.hs
{-# LANGUAGE OverloadedStrings #-}
-- | ProjectedContext layer: diff/patch algorithm implementations.
-- Contains the Myers diff algorithm, LCS, hunk building, hunk application,
-- and fuzzy patching helpers.
-- All functions are pure; no IO is performed in this module.
module AIAgent.DiffPatch.Unified.ProjectedContext.Algorithm
( lcs
, myers
, myersSearch
, buildEdits
, buildHunks
, applyHunk
, matchLines
, buildReplacement
, generateSearchIndices
, findFirstMatch
, findNearestMismatch
, applyHunkFuzzy
, Frontier
) where
import qualified Data.Text as T
import Data.Text (Text)
import Data.Array (listArray, array, (!))
import AIAgent.DiffPatch.Unified.CoreModel.Type
import AIAgent.DiffPatch.Unified.CoreModel.Constant
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU
-- | Compute an edit script from two lists of lines using the LCS algorithm.
-- Each element of the result indicates whether a line was kept, deleted, or inserted.
-- Uses dynamic programming to build an LCS table, then backtracks to produce the edit list.
lcs :: [Text] -> [Text] -> [Edit]
lcs old new = backtrack m n []
where
m = length old
n = length new
oldV = listArray (1, max 1 m) old
newV = listArray (1, max 1 n) new
-- DP table: tbl ! (i,j) = LCS length of old[1..i] and new[1..j]
tbl = array ((0, 0), (m, n))
[ ((i, j), cell i j) | i <- [0..m], j <- [0..n] ]
cell 0 _ = (0 :: Int)
cell _ 0 = 0
cell i j
| oldV ! i == newV ! j = tbl ! (i-1, j-1) + 1
| otherwise = max (tbl ! (i-1, j)) (tbl ! (i, j-1))
-- Backtrack using an accumulator to build the list in O(n) time.
-- To ensure the [Delete, Insert] order, we prioritize the Insert branch
-- when scores are tied, pushing Insert further towards the end of the final list.
backtrack i j acc
| i == 0 && j == 0 = acc
-- Match: No change
| i > 0 && j > 0 && oldV ! i == newV ! j =
backtrack (i-1) (j-1) (Keep (oldV ! i) : acc)
-- Prefer Insert branch when scores are tied to maintain
-- the conventional "Delete before Insert" ordering in the final result.
| j > 0 && (i == 0 || tbl ! (i, j-1) >= tbl ! (i-1, j)) =
backtrack i (j-1) (Insert (newV ! j) : acc)
-- Delete branch
| i > 0 =
backtrack (i-1) j (Delete (oldV ! i) : acc)
| otherwise = acc
-- ---------------------------------------------------------------------------
-- Myers diff
-- ---------------------------------------------------------------------------
-- | Unboxed vector of Int used to represent the furthest-reaching x position
-- on each diagonal k. Element at logical diagonal k is stored at physical
-- index k + offset, where offset = m + n.
type Frontier = VU.Vector Int
-- | Compute an edit script from two lists of lines using the Myers diff algorithm.
-- Implements the O(ND) algorithm from Myers (1986):
-- "An O(ND) Difference Algorithm and Its Variations"
--
-- Key concepts:
-- x : position in old (0-based, lines consumed so far, 0..m)
-- y : position in new (0-based, lines consumed so far, 0..n)
-- k = x-y : diagonal index
-- v[k] : furthest x reached on diagonal k for a given edit count d
-- snake : free diagonal advances where old[x] == new[y]
-- snapshot : immutable copy of v after each d-round, used for backtracking
--
-- CRITICAL INVARIANT: when computing the new frontier v' for edit count d,
-- all reads of v[k-1] and v[k+1] must come from the *previous* v (before any
-- updates for this d-round). This is enforced by computing all (k, x) pairs
-- first, then bulk-writing them into v'.
myers :: [Text] -> [Text] -> [Edit]
myers [] [] = []
myers old [] = map Delete old
myers [] new = map Insert new
myers oldL newL = buildEdits m n old new (myersSearch m n old new)
where
-- Convert lists to vectors for O(1) indexed access.
old = V.fromList oldL
new = V.fromList newL
m = V.length old
n = V.length new
-- | Forward phase of the Myers algorithm.
-- Iterates d = 0, 1, ..., m+n. For each d, computes the furthest x reachable
-- on every active diagonal k, reading exclusively from the previous round's
-- frontier (not the partially-updated one). Appends a snapshot of the updated
-- frontier after each round. Stops at the first d for which the endpoint
-- (x=m, y=n) is reached.
myersSearch :: Int -> Int -> V.Vector Text -> V.Vector Text -> [Frontier]
myersSearch m n old new = go 0 v0 []
where
offset = m + n
size = 2 * offset + 1
v0 = VU.replicate size 0
-- Advance diagonally from 0-based position (x, y) as long as lines match.
snake x y
| x < m && y < n && (old V.! x) == (new V.! y) = snake (x+1) (y+1)
| otherwise = x
-- Compute the new x for diagonal k during edit round d,
-- reading exclusively from the previous frontier v.
-- Boundary rules:
-- k == -d : leftmost diagonal, must come from above (Insert)
-- k == d : rightmost diagonal, must come from below (Delete)
-- tie : prefer Insert (xIns >= xDel) to match the backtracking condition
newX v d k =
let xDel = (v VU.! (k - 1 + offset)) + 1
xIns = (v VU.! (k + 1 + offset))
x0 | k == -d = xIns
| k == d = xDel
| xIns >= xDel = xIns
| otherwise = xDel
in snake x0 (x0 - k)
go d v acc =
let -- Compute all new x values from the current (old) frontier.
updates = [(k + offset, newX v d k) | k <- [-d, -d+2 .. d]]
-- Bulk-apply updates to produce the new frontier.
v' = v VU.// updates
acc' = v' : acc
xEnd = v' VU.! (m - n + offset)
yEnd = xEnd - (m - n)
in if xEnd >= m && yEnd >= n
then acc'
else go (d+1) v' acc'
-- | Backward (trace) phase of the Myers algorithm.
-- Reconstructs the edit script by walking from (m, n) back to (0, 0) through
-- the frontier snapshots in reverse order. At each step, recovers the Delete
-- or Insert move that was taken, plus any preceding snake (Keep) moves.
-- Returns the edit script in correct forward order.
buildEdits :: Int -> Int -> V.Vector Text -> V.Vector Text -> [Frontier] -> [Edit]
buildEdits m n old new snaps = go m n snaps []
where
offset = m + n
-- Walk backward along a snake: while old[x-1]==new[y-1] it was a Keep move.
unsnake x y acc
| x > 0 && y > 0 && (old V.! (x-1)) == (new V.! (y-1)) =
unsnake (x-1) (y-1) (Keep (old V.! (x-1)) : acc)
| otherwise = (x, y, acc)
-- Base: reached origin.
go 0 0 _ acc = acc
-- d=0 case: the entire path was a snake from the origin.
go x y [_] acc =
let (_, _, acc') = unsnake x y acc in acc'
-- General case: recover one edit step, then recurse with prevV.
go x y (_v:prevV:rest) acc =
let (xMid, yMid, accWithSnakes) = unsnake x y acc
k = xMid - yMid
d = length rest + 1
xFromDel = (prevV VU.! (k - 1 + offset)) + 1
xFromIns = (prevV VU.! (k + 1 + offset))
-- Mirror the tie-breaking rule used in newX (forward phase).
cameFromInsert
| k == -d = True
| k == d = False
| otherwise = xFromIns >= xFromDel
(xPrev, yPrev, edit) =
if cameFromInsert
then (xMid, yMid - 1, Insert (new V.! (yMid - 1)))
else (xMid - 1, yMid, Delete (old V.! (xMid - 1)))
in go xPrev yPrev (prevV:rest) (edit : accWithSnakes)
-- Unreachable: myersSearch guarantees snapshots cover all steps.
go _ _ [] acc = acc
-- ---------------------------------------------------------------------------
-- buildHunks
-- ---------------------------------------------------------------------------
-- | Build a list of hunks from an edit script.
-- The Int argument specifies the number of unchanged context lines surrounding each hunk.
-- Adjacent change groups separated by 2*n or fewer Keep lines are merged into one hunk.
-- Returns an empty list when the edit script contains no Delete or Insert.
buildHunks :: Int -> [Edit] -> [Hunk]
buildHunks ctx edits
| null edits = []
| otherwise = map makeHunk $ mergeGroups ctx $ groupEdits ctx numberedEdits
where
-- Annotate each Edit with 1-based old and new line numbers.
numberedEdits = numberEdits 1 1 edits
-- Assign (oldLine, newLine) to each Edit.
-- Keep and Delete advance oldLine; Keep and Insert advance newLine.
numberEdits :: Int -> Int -> [Edit] -> [(Int, Int, Edit)]
numberEdits _ _ [] = []
numberEdits ol nl (e@(Keep _):es) = (ol, nl, e) : numberEdits (ol+1) (nl+1) es
numberEdits ol nl (e@(Delete _):es) = (ol, nl, e) : numberEdits (ol+1) nl es
numberEdits ol nl (e@(Insert _):es) = (ol, nl, e) : numberEdits ol (nl+1) es
-- A "group" is a window of numbered edits that will become one hunk.
-- It is represented as a flat list of (oldLine, newLine, Edit).
-- Split the numbered edit list into raw change groups, each surrounded by
-- up to ctx context lines.
groupEdits :: Int -> [(Int, Int, Edit)] -> [[(Int, Int, Edit)]]
groupEdits n numbered =
let -- Locate all indices of Delete/Insert edits.
changeIdxs = [ i | (i, (_, _, e)) <- zip [0..] numbered, isChange e ]
in if null changeIdxs
then []
else buildGroups n numbered changeIdxs
isChange :: Edit -> Bool
isChange (Keep _) = False
isChange _ = True
-- Collect groups, merging when inter-group Keep gap <= 2*ctx.
buildGroups :: Int -> [(Int, Int, Edit)] -> [Int] -> [[(Int, Int, Edit)]]
buildGroups n numbered idxs = go clusters
where
total = length numbered
-- Cluster change indices into spans [firstChange..lastChange],
-- merging spans whose gap is <= 2*n.
clusterIdxs :: [Int] -> [(Int, Int)]
clusterIdxs [] = []
clusterIdxs (i:is) = merge i i is
where
merge lo hi [] = [(lo, hi)]
merge lo hi (x:xs)
| x - hi <= 2 * n + 1 = merge lo x xs
| otherwise = (lo, hi) : merge x x xs
clusters = clusterIdxs idxs
-- Expand each cluster span to include ctx context lines on each side.
go [] = []
go ((lo, hi):cs) =
let start = max 0 (lo - n)
end = min (total - 1) (hi + n)
in slice start end numbered : go cs
-- Extract elements from index start to end (inclusive).
slice :: Int -> Int -> [a] -> [a]
slice s e xs = take (e - s + 1) (drop s xs)
-- Build a Hunk from a group of numbered edits.
makeHunk :: [(Int, Int, Edit)] -> Hunk
makeHunk [] = Hunk 0 0 0 0 []
makeHunk group =
let (oldStart, newStart, _) = head group
oldCount = length [ () | (_, _, e) <- group, isOldLine e ]
newCount = length [ () | (_, _, e) <- group, isNewLine e ]
hlines = map toLine group
in Hunk oldStart oldCount newStart newCount hlines
isOldLine :: Edit -> Bool
isOldLine (Insert _) = False
isOldLine _ = True
isNewLine :: Edit -> Bool
isNewLine (Delete _) = False
isNewLine _ = True
toLine :: (Int, Int, Edit) -> Line
toLine (_, _, Keep t) = Line Context t
toLine (_, _, Delete t) = Line Removed t
toLine (_, _, Insert t) = Line Added t
-- Merge adjacent groups when their Keep gap falls within 2*ctx.
-- groupEdits already handles merging via clusterIdxs; this is a no-op
-- pass-through kept for symmetry.
mergeGroups :: Int -> [[(Int, Int, Edit)]] -> [[(Int, Int, Edit)]]
mergeGroups _ = id
-- ---------------------------------------------------------------------------
-- applyHunk
-- ---------------------------------------------------------------------------
-- | Apply a single hunk to a list of lines.
-- The source lines must match the Context and Removed lines declared in the hunk;
-- otherwise a PatchError is returned.
--
-- Algorithm:
-- 1. Validate that _oldStartHunk is within range (1 .. length src + 1).
-- 2. Split src into before / target / after blocks using _oldStartHunk and _oldCountHunk.
-- 3. Walk _linesHunk, verifying each Context or Removed line against target.
-- Return HunkMismatch (with 1-based line number) on the first discrepancy.
-- 4. Build the replacement block from Context and Added lines in _linesHunk.
-- 5. Return Right (before ++ replacement ++ after).
applyHunk :: [Text] -> Hunk -> Either PatchError [Text]
applyHunk src hunk
-- _oldStartHunk must be in the range [1 .. length src + 1].
-- (+1 allows a hunk that starts right after the last line, e.g. pure insertion at end.)
| _oldStartHunk hunk < 1 || _oldStartHunk hunk > length src + 1 =
Left $ InvalidPatch $ T.pack $
"_oldStartHunk " ++ show (_oldStartHunk hunk) ++
" is out of range for source of " ++ show (length src) ++ " lines"
| otherwise =
let startIdx = _oldStartHunk hunk - 1 -- convert to 0-based
before = take startIdx src
target = take (_oldCountHunk hunk) (drop startIdx src)
after = drop (startIdx + _oldCountHunk hunk) src
in case matchLines (_oldStartHunk hunk) (_linesHunk hunk) target of
Left err -> Left err
Right () -> Right (before ++ buildReplacement (_linesHunk hunk) ++ after)
-- | Verify that Context and Removed lines in the hunk match the target lines in order.
-- Returns HunkMismatch with the 1-based source line number on the first discrepancy.
matchLines :: Int -> [Line] -> [Text] -> Either PatchError ()
matchLines lineNo hlines target = go lineNo hlines target
where
go _ [] _ = Right ()
go ln (line : rest) src =
case _typeLine line of
-- Added lines do not consume a source line; skip them.
Added -> go ln rest src
-- Context and Removed lines must match the next source line.
Context -> checkLine ln (_contentLine line) rest src
Removed -> checkLine ln (_contentLine line) rest src
checkLine ln expected rest src =
case src of
[] -> Left $ HunkMismatch ln expected
(s:ss) ->
if s == expected
then go (ln + 1) rest ss
else Left $ HunkMismatch ln expected
-- | Extract the replacement lines from a hunk: Context and Added lines only.
-- Removed lines are dropped (they are replaced by Added lines).
buildReplacement :: [Line] -> [Text]
buildReplacement = foldr step []
where
step line acc =
case _typeLine line of
Removed -> acc
_ -> _contentLine line : acc
-- ---------------------------------------------------------------------------
-- Fuzzy Patching helpers
-- ---------------------------------------------------------------------------
-- | Generate candidate 0-based indices in priority order: center first,
-- then alternating outward (center+1, center-1, center+2, center-2, ...).
-- Indices outside [0, maxLen] are excluded.
generateSearchIndices :: Int -> Int -> Int -> [Int]
generateSearchIndices center fuzz maxLen =
filter (\i -> i >= 0 && i <= maxLen) $
center : concatMap (\d -> [center + d, center - d]) [1 .. fuzz]
-- | Try each candidate 0-based index in order and return the first one for
-- which matchLines succeeds. Does not apply the hunk; only confirms position.
findFirstMatch :: [Text] -> Hunk -> [Int] -> Maybe Int
findFirstMatch _ _ [] = Nothing
findFirstMatch src hunk (idx:idxs) =
let target = take (_oldCountHunk hunk) (drop idx src)
lineNo = idx + 1 -- matchLines expects a 1-based line number
in case matchLines lineNo (_linesHunk hunk) target of
Right () -> Just idx
Left _ -> findFirstMatch src hunk idxs
-- | Find the candidate position that produced the deepest (nearest) mismatch.
-- Returns Just (1-based line number, expected text, actual text) for the best candidate,
-- or Nothing when the candidate list is empty or all candidates have empty targets.
-- Used to build diagnostic messages when fuzzy search fails entirely.
findNearestMismatch :: [Text] -> Hunk -> [Int] -> Maybe (Int, Text, Text)
findNearestMismatch src hunk idxs = go idxs Nothing (0 :: Int)
where
-- Walk through candidates, tracking the best (deepest) mismatch seen so far.
go :: [Int] -> Maybe (Int, Text, Text) -> Int -> Maybe (Int, Text, Text)
go [] best _ = best
go (idx:rest) best bestDepth =
let target = take (_oldCountHunk hunk) (drop idx src)
lineNo = idx + 1
in case probeDepth lineNo (_linesHunk hunk) target 0 of
Nothing -> go rest best bestDepth -- empty target
Just (depth, info) ->
if depth > bestDepth
then go rest (Just info) depth
else go rest best bestDepth
-- Walk hlines against target, counting how many lines matched before failing.
-- Returns Just (matchDepth, (1-based lineNo, expected, actual)) on mismatch,
-- Nothing when no mismatch was found (full match or empty hlines).
probeDepth :: Int -> [Line] -> [Text] -> Int -> Maybe (Int, (Int, Text, Text))
probeDepth _ [] _ _ = Nothing
probeDepth ln (line:rest) src' depth =
case _typeLine line of
Added -> probeDepth ln rest src' depth
Context -> checkProbe ln (_contentLine line) rest src' depth
Removed -> checkProbe ln (_contentLine line) rest src' depth
checkProbe :: Int -> Text -> [Line] -> [Text] -> Int -> Maybe (Int, (Int, Text, Text))
checkProbe ln expected rest src' depth =
case src' of
[] -> Just (depth, (ln, expected, ""))
(s:ss) ->
if s == expected
then probeDepth (ln+1) rest ss (depth+1)
else Just (depth, (ln, expected, s))
-- | Format a hunk header as "@@ -os,oc +ns,nc @@" for use in diagnostic messages.
formatHunkHeader :: Hunk -> String
formatHunkHeader h =
"@@ -" ++ show (_oldStartHunk h) ++ "," ++ show (_oldCountHunk h)
++ " +" ++ show (_newStartHunk h) ++ "," ++ show (_newCountHunk h)
++ " @@"
-- | Fuzzy-aware variant of applyHunk.
-- Takes a 1-based hunk index for diagnostic messages.
-- Uses the delta-corrected _oldStartHunk as a hint and scans ±_FUZZY_RANGE lines
-- for the first position where context lines match, then applies the hunk there.
-- Falls back to a rich InvalidPatch message when no candidate matches.
applyHunkFuzzy :: Int -> [Text] -> Hunk -> Either PatchError [Text]
applyHunkFuzzy hunkIdx src hunk =
let preferredIdx = _oldStartHunk hunk - 1 -- convert 1-based hint to 0-based
candidates = generateSearchIndices preferredIdx _FUZZY_RANGE (length src)
loLine = preferredIdx - _FUZZY_RANGE
hiLine = preferredIdx + _FUZZY_RANGE
-- Count Context+Removed lines declared in the hunk.
actualOldLines = length [ l | l <- _linesHunk hunk
, _typeLine l `elem` [Context, Removed] ]
countMismatch = actualOldLines /= _oldCountHunk hunk
in case findFirstMatch src hunk candidates of
Just bestIdx ->
applyHunk src (hunk { _oldStartHunk = bestIdx + 1 }) -- restore 1-based
Nothing ->
Left $ InvalidPatch $ T.pack $ unlines $
[ "fuzzy search failed"
, " hunk #" ++ show hunkIdx ++ ": " ++ formatHunkHeader hunk
, " preferred line: " ++ show (_oldStartHunk hunk)
, " searched: " ++ show (max 1 loLine) ++ ".." ++ show (min (length src) hiLine)
, " old lines in hunk (Context+Removed): " ++ show actualOldLines
++ ", oldCount in header: " ++ show (_oldCountHunk hunk)
++ if countMismatch then " [MISMATCH]" else ""
] ++
case findNearestMismatch src hunk candidates of
Nothing -> []
Just (ln, exp', act) ->
[ " nearest mismatch at line " ++ show ln ++ ":"
, " expected: " ++ show exp'
, " actual: " ++ show act
]