hydra-0.15.0: src/main/haskell/Hydra/Demos/Shacl/Demo.hs
{-# LANGUAGE ScopedTypeVariables #-}
-- | Haskell driver for the SHACL demo.
--
-- Generates SHACL shapes from the hydra.module.Module type, encodes kernel
-- modules from JSON as conforming RDF data, and writes both to N-Triples files.
--
-- Usage: runhaskell Demo.hs <json-dir> <output-dir>
--
-- Where:
-- <json-dir> is the path to dist/json/hydra-kernel/src/main/json/
-- <output-dir> is the directory for output N-Triples files
module Hydra.Demos.Shacl.Demo where
import Hydra.Kernel
import Hydra.Generation (modulesToGraph, loadModulesFromJson, readManifestField)
import Hydra.Sources.All (kernelModules)
import Hydra.Module.Compat (moduleBindings)
import qualified Hydra.Shacl.Coder as ShaclCoder
import qualified Hydra.Rdf.Syntax as Rdf
import qualified Hydra.Shacl.Model as Shacl
import qualified Hydra.Rdf.Utils as RdfUtils
import qualified Hydra.Rdf.Serde as Serde
import qualified Hydra.Decode.Core as DecodeCore
import qualified Hydra.Encode.Packaging as EncodePackaging
import qualified Hydra.Context as Context
import qualified Hydra.Lexical as Lexical
import Hydra.Demos.Shacl.ShaclRdf (shapesGraphToTriples)
import qualified Data.List as L
import qualified Data.Map as M
import qualified Data.Set as S
import Control.Exception (catch, SomeException, evaluate)
import System.CPUTime (getCPUTime)
import System.Directory (createDirectoryIfMissing)
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.IO (hPutStrLn, stderr, hFlush, stdout)
main :: IO ()
main = do
args <- getArgs
case args of
[jsonDir, outDir] -> runDemo jsonDir outDir
_ -> do
hPutStrLn stderr "Usage: ShaclDemo <json-dir> <output-dir>"
exitFailure
runDemo :: FilePath -> FilePath -> IO ()
runDemo jsonDir outDir = do
createDirectoryIfMissing True outDir
-- Step 1: Build a graph from the kernel type modules
putStrLn "Step 1: Building graph from kernel type modules..."
let graph = modulesToGraph kernelModules kernelModules
let cx = Lexical.emptyContext
-- Step 2: Generate SHACL shapes from kernel type elements, skipping unsupported types
putStrLn "Step 2: Generating SHACL shapes from kernel type modules..."
startTime <- getCPUTime
let allTypeEls = concatMap (\m -> filter isNativeType (moduleBindings m)) kernelModules
let (shapes, nSkipped) = encodeShapes cx graph allTypeEls
let allTriples = shapesGraphToTriples shapes
let shapesNt = triplesToNtriples allTriples
let shapesFile = outDir </> "shapes.nt"
writeFile shapesFile shapesNt
shapesEnd <- getCPUTime
putStrLn $ " Generated " ++ show (S.size (Shacl.unShapesGraph shapes)) ++ " shapes (" ++ show nSkipped ++ " types skipped)"
putStrLn $ " Wrote " ++ show (length allTriples) ++ " triples to " ++ shapesFile
putStrLn $ " Time: " ++ showTimeMs (shapesEnd - startTime)
-- Step 3: Load kernel modules from JSON and encode as RDF
putStrLn "Step 3: Loading kernel modules from JSON and encoding as RDF..."
dataStart <- getCPUTime
kernelNamespaces <- readManifestField jsonDir "mainModules"
-- Load the first type-defining modules (those whose types we encoded as shapes)
let typeNs = take 15 kernelNamespaces
loadedModules <- loadModulesFromJson jsonDir kernelModules typeNs
-- Encode each loaded module *as a Module term* into RDF
(allDataDescs, nDataSkipped) <- encodeModulesAsRdf cx graph loadedModules
let dataNt = Serde.rdfGraphToNtriples $ RdfUtils.descriptionsToGraph allDataDescs
let dataFile = outDir </> "data.nt"
writeFile dataFile dataNt
dataEnd <- getCPUTime
putStrLn $ " Encoded " ++ show (length allDataDescs) ++ " descriptions (" ++ show nDataSkipped ++ " elements skipped)"
putStrLn $ " Wrote to " ++ dataFile
putStrLn $ " Time: " ++ showTimeMs (dataEnd - dataStart)
-- Step 4: Generate intentionally non-conforming RDF
putStrLn "Step 4: Generating non-conforming RDF data..."
let invalidNt = generateInvalidData
let invalidFile = outDir </> "invalid.nt"
writeFile invalidFile invalidNt
putStrLn $ " Wrote to " ++ invalidFile
-- Summary
let totalMs = showTimeMs (dataEnd - startTime)
putStrLn ""
putStrLn $ "Done. Output files in " ++ outDir
putStrLn $ " shapes.nt: SHACL shapes graph (" ++ show (S.size (Shacl.unShapesGraph shapes)) ++ " shapes, " ++ show (length allTriples) ++ " triples)"
putStrLn $ " data.nt: conforming RDF data (" ++ show (length allDataDescs) ++ " descriptions)"
putStrLn $ " invalid.nt: non-conforming RDF data"
hPutStrLn stderr $ "HYDRA_TIME_MS=" ++ totalMs
-- | Encode type elements as SHACL shapes, skipping types that the SHACL language
-- doesn't support (function types, type variables, etc.). Returns the shapes graph
-- and the count of skipped elements.
encodeShapes :: Context.Context -> Graph -> [Binding] -> (Shacl.ShapesGraph, Int)
encodeShapes cx graph elements =
let results = map (encodeOneShape cx graph) elements
successes = [d | Right d <- results]
failures = length [() | Left _ <- results]
in (Shacl.ShapesGraph (S.fromList successes), failures)
-- | Try to encode a single type element as a SHACL Definition<Shape>.
-- Replicates the toShape logic from shaclCoder but returns Either for error recovery.
encodeOneShape :: Context.Context -> Graph -> Binding -> Either String (Shacl.Definition Shacl.Shape)
encodeOneShape cx graph el =
case DecodeCore.type_ graph (bindingTerm el) of
Left de -> Left $ "decode error: " ++ unDecodingError de
Right typ ->
case ShaclCoder.encodeType (bindingName el) typ cx of
Left _ic -> Left $ "encode error for " ++ unName (bindingName el)
Right cp -> Right $ Shacl.Definition {
Shacl.definitionIri = RdfUtils.nameToIri (bindingName el),
Shacl.definitionTarget = Shacl.ShapeNode (Shacl.NodeShape cp) }
-- | Encode loaded modules as RDF. Each Module is simplified (element terms stripped)
-- then converted to a term and encoded as RDF via the SHACL term encoder.
-- This is necessary because Module elements can contain arbitrary Hydra terms
-- (functions, type definitions) that the SHACL term encoder doesn't support.
encodeModulesAsRdf :: Context.Context -> Graph -> [Module] -> IO ([Rdf.Description], Int)
encodeModulesAsRdf cx graph mods = go cx mods [] 0
where
go _ [] descs nSkip = return (descs, nSkip)
go cx' (m:ms) descs nSkip = do
let ns = unNamespace (moduleNamespace m)
subject = Rdf.ResourceIri $ Rdf.Iri ("urn:hydra:module:" ++ ns)
simplified = simplifyModule m
moduleTerm = EncodePackaging.module_ simplified
result <- tryEncode subject moduleTerm cx' graph
case result of
Nothing -> do
hPutStrLn stderr $ " Skipped: " ++ ns
go cx' ms descs (nSkip + 1)
Just (mDescs, cx'') -> do
putStrLn $ " Encoded: " ++ ns ++ " (" ++ show (length mDescs) ++ " descriptions)"
go cx'' ms (descs ++ mDescs) nSkip
-- | Simplify a module for RDF encoding by replacing each element's term body
-- with a string literal of the element name. This preserves the module structure
-- (namespace, element names, type schemes, dependencies, description) while
-- avoiding unsupported term variants (functions, type applications, etc.).
simplifyModule :: Module -> Module
simplifyModule m = m { moduleDefinitions = map simplifyDef (moduleDefinitions m) }
where
simplifyDef (DefinitionTerm td) = DefinitionTerm $ td {
termDefinitionTerm = TermLiteral (LiteralString (unName (termDefinitionName td))) }
simplifyDef d = d
-- | Try to encode a term as RDF, catching both Either errors and exceptions.
tryEncode :: Rdf.Resource -> Term -> Context.Context -> Graph
-> IO (Maybe ([Rdf.Description], Context.Context))
tryEncode subject term cx graph =
catch
(case ShaclCoder.encodeTerm subject term cx graph of
Left _ic -> return Nothing
Right (descs, cx') -> do
-- Force full evaluation to catch lazy exceptions
let nt = Serde.rdfGraphToNtriples $ RdfUtils.descriptionsToGraph descs
_ <- evaluate (length nt)
return (Just (descs, cx')))
(\(_e :: SomeException) -> return Nothing)
-- | Generate non-conforming RDF data that should fail SHACL validation.
-- Creates instances typed as kernel types but with missing required fields,
-- wrong value types, etc.
generateInvalidData :: String
generateInvalidData = triplesToNtriples $
-- A Module missing its required "namespace" field
[ triple "urn:invalid:module1" (rdfType) "urn:hydra.module.Module"
, tripleL "urn:invalid:module1" "urn:hydra.module#moduleDescription" "A module with no namespace"
] ++
-- A Namespace with a non-string value (integer instead of string)
[ triple "urn:invalid:ns1" (rdfType) "urn:hydra.module.Namespace"
, tripleInt "urn:invalid:ns1" "urn:hydra.module#namespace" 42
] ++
-- A QualifiedName missing its required "local" field
[ triple "urn:invalid:qn1" (rdfType) "urn:hydra.module.QualifiedName"
]
where
rdfType = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
triple s p o = Rdf.Triple
(Rdf.ResourceIri (Rdf.Iri s))
(Rdf.Iri p)
(Rdf.NodeIri (Rdf.Iri o))
tripleL s p v = Rdf.Triple
(Rdf.ResourceIri (Rdf.Iri s))
(Rdf.Iri p)
(Rdf.NodeLiteral (Rdf.Literal v (Rdf.Iri "http://www.w3.org/2001/XMLSchema#string") Nothing))
tripleInt s p n = Rdf.Triple
(Rdf.ResourceIri (Rdf.Iri s))
(Rdf.Iri p)
(Rdf.NodeLiteral (Rdf.Literal (show n) (Rdf.Iri "http://www.w3.org/2001/XMLSchema#integer") Nothing))
-- | Convert triples to N-Triples string via Description intermediary
triplesToNtriples :: [Rdf.Triple] -> String
triplesToNtriples triples =
Serde.rdfGraphToNtriples $ Rdf.Graph (S.fromList triples)
-- | Format picoseconds as milliseconds string
showTimeMs :: Integer -> String
showTimeMs ps = show (fromIntegral ps / 1e9 :: Double)