swarm-0.4: src/Swarm/Doc/Pedagogy.hs
{-# LANGUAGE OverloadedStrings #-}
-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Assess pedagogical soundness of the tutorials.
--
-- Approach:
-- 1. Obtain a list of all of the tutorial scenarios, in order
-- 2. Search their "solution" code for `commands`
-- 3. "fold" over the tutorial list, noting which tutorial was first to introduce each command
module Swarm.Doc.Pedagogy (
renderTutorialProgression,
generateIntroductionsSequence,
CoverageInfo (..),
TutorialInfo (..),
) where
import Control.Lens (universe, view, (^.))
import Control.Monad (guard)
import Data.List (foldl', intercalate, sort, sortOn)
import Data.List.Extra (zipFrom)
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (mapMaybe)
import Data.Sequence (Seq)
import Data.Set (Set)
import Data.Set qualified as S
import Data.Text (Text)
import Data.Text qualified as T
import Swarm.Constant
import Swarm.Game.Entity (loadEntities)
import Swarm.Game.Failure (SystemFailure)
import Swarm.Game.Scenario (Scenario, scenarioDescription, scenarioName, scenarioObjectives, scenarioSolution)
import Swarm.Game.Scenario.Objective (objectiveGoal)
import Swarm.Game.ScenarioInfo (ScenarioCollection, ScenarioInfoPair, flatten, loadScenarios, scenarioCollectionToList, scenarioPath)
import Swarm.Game.World.Load (loadWorlds)
import Swarm.Language.Module (Module (..))
import Swarm.Language.Pipeline (ProcessedTerm (..))
import Swarm.Language.Syntax
import Swarm.Language.Text.Markdown (findCode)
import Swarm.Language.Types (Polytype)
import Swarm.TUI.Controller (getTutorials)
import Swarm.Util.Effect (ignoreWarnings, simpleErrorHandle)
-- * Constants
commandsWikiAnchorPrefix :: Text
commandsWikiAnchorPrefix = wikiCheatSheet <> "#"
-- * Types
-- | Tutorials augmented by the set of
-- commands that they introduce.
-- Generated by folding over all of the
-- tutorials in sequence.
data CoverageInfo = CoverageInfo
{ tutInfo :: TutorialInfo
, novelSolutionCommands :: Map Const [SrcLoc]
}
-- | Tutorial scenarios with the set of commands
-- introduced in their solution and descriptions
-- having been extracted
data TutorialInfo = TutorialInfo
{ scenarioPair :: ScenarioInfoPair
, tutIndex :: Int
, solutionCommands :: Map Const [SrcLoc]
, descriptionCommands :: Set Const
}
-- | A private type used by the fold
data CommandAccum = CommandAccum
{ _encounteredCmds :: Set Const
, tuts :: [CoverageInfo]
}
-- * Functions
-- | Extract commands from both goal descriptions and solution code.
extractCommandUsages :: Int -> ScenarioInfoPair -> TutorialInfo
extractCommandUsages idx siPair@(s, _si) =
TutorialInfo siPair idx solnCommands $ getDescCommands s
where
solnCommands = getCommands maybeSoln
maybeSoln = view scenarioSolution s
-- | Obtain the set of all commands mentioned by
-- name in the tutorial's goal descriptions.
getDescCommands :: Scenario -> Set Const
getDescCommands s = S.fromList $ concatMap filterConst allCode
where
goalTextParagraphs = view objectiveGoal <$> view scenarioObjectives s
allCode = concatMap findCode goalTextParagraphs
filterConst :: Syntax -> [Const]
filterConst sx = mapMaybe toConst $ universe (sx ^. sTerm)
toConst :: Term -> Maybe Const
toConst = \case
TConst c -> Just c
_ -> Nothing
isConsidered :: Const -> Bool
isConsidered c = isUserFunc c && c `S.notMember` ignoredCommands
where
ignoredCommands = S.fromList [Run, Return, Noop, Force]
-- | Extract the command names from the source code of the solution.
--
-- NOTE: `noop` gets automatically inserted for an empty `build {}` command
-- at parse time, so we explicitly ignore the `noop` in the case that
-- the player did not write it explicitly in their code.
--
-- Also, the code from `run` is not parsed transitively yet.
getCommands :: Maybe ProcessedTerm -> Map Const [SrcLoc]
getCommands Nothing = mempty
getCommands (Just (ProcessedTerm (Module stx _) _ _)) =
M.fromListWith (<>) $ mapMaybe isCommand nodelist
where
nodelist :: [Syntax' Polytype]
nodelist = universe stx
isCommand (Syntax' sloc t _) = case t of
TConst c -> guard (isConsidered c) >> Just (c, [sloc])
_ -> Nothing
-- | "fold" over the tutorials in sequence to determine which
-- commands are novel to each tutorial's solution.
computeCommandIntroductions :: [(Int, ScenarioInfoPair)] -> [CoverageInfo]
computeCommandIntroductions =
reverse . tuts . foldl' f initial
where
initial = CommandAccum mempty mempty
f :: CommandAccum -> (Int, ScenarioInfoPair) -> CommandAccum
f (CommandAccum encounteredPreviously xs) (idx, siPair) =
CommandAccum updatedEncountered $ CoverageInfo usages novelCommands : xs
where
usages = extractCommandUsages idx siPair
usedCmdsForTutorial = solutionCommands usages
updatedEncountered = encounteredPreviously `S.union` M.keysSet usedCmdsForTutorial
novelCommands = M.withoutKeys usedCmdsForTutorial encounteredPreviously
-- | Extract the tutorials from the complete scenario collection
-- and derive their command coverage info.
generateIntroductionsSequence :: ScenarioCollection -> [CoverageInfo]
generateIntroductionsSequence =
computeCommandIntroductions . zipFrom 0 . getTuts
where
getTuts =
concatMap flatten
. scenarioCollectionToList
. getTutorials
-- * Rendering functions
-- | Helper for standalone rendering.
-- For unit tests, can instead access the scenarios via the GameState.
loadScenarioCollection :: IO ScenarioCollection
loadScenarioCollection = simpleErrorHandle $ do
entities <- loadEntities
-- Note we ignore any warnings generated by 'loadWorlds' and
-- 'loadScenarios' below. Any warnings will be caught when loading
-- all the scenarios via the usual code path; we do not need to do
-- anything with them here while simply rendering pedagogy info.
worlds <- ignoreWarnings @(Seq SystemFailure) $ loadWorlds entities
ignoreWarnings @(Seq SystemFailure) $ loadScenarios entities worlds
renderUsagesMarkdown :: CoverageInfo -> Text
renderUsagesMarkdown (CoverageInfo (TutorialInfo (s, si) idx _sCmds dCmds) novelCmds) =
T.unlines bodySections
where
bodySections = firstLine : otherLines
otherLines =
intercalate
[""]
[ pure . surround "`" . T.pack $ view scenarioPath si
, pure . surround "*" . T.strip $ view scenarioDescription s
, renderSection "Introduced in solution" . renderCmdList $ M.keysSet novelCmds
, renderSection "Referenced in description" $ renderCmdList dCmds
]
surround x y = x <> y <> x
renderSection title content =
["### " <> title] <> content
firstLine =
T.unwords
[ "##"
, renderTutorialTitle idx s
]
renderTutorialTitle :: (Show a) => a -> Scenario -> Text
renderTutorialTitle idx s =
T.unwords
[ T.pack $ show idx <> ":"
, view scenarioName s
]
linkifyCommand :: Text -> Text
linkifyCommand c = "[" <> c <> "](" <> commandsWikiAnchorPrefix <> c <> ")"
renderList :: [Text] -> [Text]
renderList items =
if null items
then pure "(none)"
else map ("* " <>) items
cmdSetToSortedText :: Set Const -> [Text]
cmdSetToSortedText = sort . map (T.pack . show) . S.toList
renderCmdList :: Set Const -> [Text]
renderCmdList = renderList . map linkifyCommand . cmdSetToSortedText
renderTutorialProgression :: IO Text
renderTutorialProgression =
processAndRender <$> loadScenarioCollection
where
processAndRender ss =
T.unlines allLines
where
introSection =
"# Command introductions by tutorial"
: "This document indicates which tutorials introduce various commands and keywords."
: ""
: "All used:"
: renderFullCmdList allUsed
render (cmd, tut) =
T.unwords
[ linkifyCommand cmd
, "(" <> renderTutorialTitle (tutIndex tut) (fst $ scenarioPair tut) <> ")"
]
renderFullCmdList = renderList . map render . sortOn fst
infos = generateIntroductionsSequence ss
allLines = introSection <> map renderUsagesMarkdown infos
allUsed = concatMap mkTuplesForTutorial infos
mkTuplesForTutorial tut =
map (\x -> (T.pack $ show x, tutIdxScenario)) $
M.keys $
novelSolutionCommands tut
where
tutIdxScenario = tutInfo tut