diff --git a/previous-exams/2022-final-solutions/concpar22final01/.gitignore b/previous-exams/2022-final-solutions/concpar22final01/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final-solutions/concpar22final01/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final01/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final-solutions/concpar22final01/build.sbt b/previous-exams/2022-final-solutions/concpar22final01/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..beb0e5c73b7884cfa88403c52a88d365aa94e1fe
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/build.sbt
@@ -0,0 +1,11 @@
+course := "concpar"
+assignment := "concpar22final01"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/build.properties b/previous-exams/2022-final-solutions/concpar22final01/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala
new file mode 100644
index 0000000000000000000000000000000000000000..d555c0dbbd5675b764bcd26a37886d0af9a6d88e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala
@@ -0,0 +1,65 @@
+package concpar22final01
+
+trait Problem1 extends Lib:
+
+  class DLLCombinerImplementation extends DLLCombiner:
+
+    // Copies every other Integer element of data array, starting from the first (index 0), up to the middle
+    def task1(data: Array[Int]) = task {
+
+      var current = first
+      var i = 0
+      while current != null && i < size / 2 do
+        data(i) = current.value
+        i += 2
+        current = current.getNext2
+    }
+
+    // Copies every other Integer element of data array, starting from the second, up to the middle
+    def task2(data: Array[Int]) = task {
+
+      var current = second
+      var i = 1
+      while current != null && i < size / 2 do
+        data(i) = current.value
+        i += 2
+        current = current.getNext2
+    }
+
+    // Copies every other Integer element of data array, starting from the second to last, up to the middle
+    def task3(data: Array[Int]) = task {
+
+      var current = secondToLast
+      var i = size - 2
+      while current != null && i >= size / 2 do
+        data(i) = current.value
+        i -= 2
+        current = current.getPrevious2
+    }
+
+    // Copies every other Integer element of data array, starting from the last, up to the middle
+    // This is executed on the current thread.
+    def task4(data: Array[Int]) =
+
+      var current = last
+      var i = size - 1
+      while current != null && i >= size / 2 do
+        data(i) = current.value
+        i -= 2
+        current = current.getPrevious2
+
+    def result(): Array[Int] =
+      val data = new Array[Int](size)
+
+      val t1 = task1(data)
+      val t2 = task2(data)
+      val t3 = task3(data)
+
+      task4(data)
+
+      t1.join()
+      t2.join()
+      t3.join()
+
+
+      data
diff --git a/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala
new file mode 100644
index 0000000000000000000000000000000000000000..6d9d6ee079efaafce9a4a0746a159de2848494dd
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala
@@ -0,0 +1,81 @@
+package concpar22final01
+
+import java.util.concurrent.*
+import scala.util.DynamicVariable
+
+trait Lib:
+  class Node(val value: Int):
+    protected var next: Node = null // null for last node.
+    protected var next2: Node = null // null for last node.
+    protected var previous: Node = null // null for first node.
+    protected var previous2: Node = null // null for first node.
+
+    def getNext: Node = next // do NOT use in the result method
+    def getNext2: Node = next2
+    def getPrevious: Node = previous // do NOT use in the result method
+    def getPrevious2: Node = previous2
+
+    def setNext(n: Node): Unit = next = n
+    def setNext2(n: Node): Unit = next2 = n
+    def setPrevious(n: Node): Unit = previous = n
+    def setPrevious2(n: Node): Unit = previous2 = n
+
+  // Simplified Combiner interface
+  // Implements methods += and combine
+  // Abstract methods should be implemented in subclasses
+  abstract class DLLCombiner:
+    var first: Node = null // null for empty lists.
+    var last: Node = null // null for empty lists.
+
+    var second: Node = null // null for empty lists.
+    var secondToLast: Node = null // null for empty lists.
+
+    var size: Int = 0
+
+    // Adds an Integer to this array combiner.
+    def +=(elem: Int): Unit =
+      val node = new Node(elem)
+      if size == 0 then
+        first = node
+        last = node
+        size = 1
+      else
+        last.setNext(node)
+        node.setPrevious(last)
+        node.setPrevious2(last.getPrevious)
+        if size > 1 then last.getPrevious.setNext2(node)
+        else second = node
+        secondToLast = last
+        last = node
+        size += 1
+
+    // Combines this array combiner and another given combiner in constant O(1) complexity.
+    def combine(that: DLLCombiner): DLLCombiner =
+      if this.size == 0 then that
+      else if that.size == 0 then this
+      else
+        this.last.setNext(that.first)
+        this.last.setNext2(that.first.getNext)
+        if this.last.getPrevious != null then
+          this.last.getPrevious.setNext2(that.first) // important
+
+        that.first.setPrevious(this.last)
+        that.first.setPrevious2(this.last.getPrevious)
+        if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important
+
+        if this.size == 1 then second = that.first
+
+        this.size = this.size + that.size
+        this.last = that.last
+        this.secondToLast = that.secondToLast
+
+        this
+
+    def task1(data: Array[Int]): ForkJoinTask[Unit]
+    def task2(data: Array[Int]): ForkJoinTask[Unit]
+    def task3(data: Array[Int]): ForkJoinTask[Unit]
+    def task4(data: Array[Int]): Unit
+
+    def result(): Array[Int]
+
+  def task[T](body: => T): ForkJoinTask[T]
diff --git a/previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala b/previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a65007252c5cebb91bf2b74a37433fcf040fade7
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala
@@ -0,0 +1,491 @@
+package concpar22final01
+
+import java.util.concurrent.*
+import scala.util.DynamicVariable
+
+class Problem1Suite extends AbstractProblem1Suite:
+
+  test("[Public] fetch simple result without combining (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 7
+    combiner1 += 2
+    combiner1 += 3
+    combiner1 += 8
+    combiner1 += 1
+    combiner1 += 2
+    combiner1 += 3
+    combiner1 += 8
+
+    val result = combiner1.result()
+    val array = Array(7, 2, 3, 8, 1, 2, 3, 8)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+  test("[Public] fetch result without combining (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 7
+    combiner1 += 2
+    combiner1 += 3
+    combiner1 += 8
+    combiner1 += 1
+
+    val result = combiner1.result()
+    val array = Array(7, 2, 3, 8, 1)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+  test("[Public] fetch result after simple combining (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 7
+    combiner1 += 2
+
+    val combiner2 = new DLLCombinerTest
+    combiner2 += 3
+    combiner2 += 8
+
+    val combiner3 = new DLLCombinerTest
+    combiner3 += 1
+    combiner3 += 9
+
+    val combiner4 = new DLLCombinerTest
+    combiner4 += 3
+    combiner4 += 2
+
+    val result = combiner1.combine(combiner2).combine(combiner3).combine(combiner4).result()
+    val array = Array(7, 2, 3, 8, 1, 9, 3, 2)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+  test("[Public] fetch result - small combiner (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 4
+    combiner1 += 2
+    combiner1 += 6
+
+    val result = combiner1.result()
+    val array = Array(4, 2, 6)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+
+  // (25+) 15 / 250 points for correct implementation, don't check parallelism
+  test("[Correctness] fetch result - simple combiners (2pts)") {
+    assertCorrectnessSimple()
+  }
+
+  test("[Correctness] fetch result - small combiners (3pts)") {
+    assertCorrectnessBasic()
+  }
+
+  test("[Correctness] fetch result - small combiners after combining (5pts)") {
+    assertCorrectnessCombined()
+  }
+
+  test("[Correctness] fetch result - large combiners (5pts)") {
+    assertCorrectnessLarge()
+  }
+
+  def assertCorrectnessSimple() = simpleCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  def assertCorrectnessBasic() = basicCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  def assertCorrectnessCombined() =
+    combinedCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  def assertCorrectnessLarge() = largeCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  // (25+15+) 25 / 250 points for correct parallel implementation, don't check if it's exactly 1/4 of the array per task
+  private var count = 0
+  private val expected = 3
+
+  override def task[T](body: => T): ForkJoinTask[T] =
+    count += 1
+    scheduler.value.schedule(body)
+
+  test("[TaskCount] number of newly created tasks should be 3 (5pts)") {
+    assertTaskCountSimple()
+  }
+
+  test("[TaskCount] fetch result and check parallel - simple combiners (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessSimple()
+  }
+
+  test("[TaskCount] fetch result and check parallel - small combiners (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessBasic()
+  }
+
+  test("[TaskCount] fetch result and check parallel - small combiners after combining (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessCombined()
+  }
+
+  test("[TaskCount] fetch result and check parallel - large combiners (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessLarge()
+  }
+
+  def assertTaskCountSimple(): Unit =
+    simpleCombiners.foreach(elem => assertTaskCount(elem._1, elem._2))
+
+  def assertTaskCount(combiner: DLLCombinerTest, array: Array[Int]): Unit =
+    try
+      count = 0
+      build(combiner, array)
+      combiner.result()
+      assertEquals(
+        count,
+        expected, {
+          s"ERROR: Expected $expected instead of $count calls to `task(...)`"
+        }
+      )
+    finally count = 0
+
+  // (25+15+25+) 50 / 250 points for correct implementation that uses only next2 and previous2, and not next and previous
+  test("[Skip2] fetch parallel result and check skip2 - simple combiners (10pts)") {
+    assertTaskCountSimple()
+    assertSkipSimple()
+    assertCorrectnessSimple()
+  }
+
+  test("[Skip2] fetch result and check skip2 - simple combiners (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessSimple()
+  }
+
+  test("[Skip2] fetch result and check skip2 - small combiners (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessBasic()
+  }
+
+  test("[Skip2] fetch result and check skip2 - small combiners after combining (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessCombined()
+  }
+
+  test("[Skip2] fetch result and check skip2 - large combiners (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessLarge()
+  }
+
+  def assertSkipSimple(): Unit = simpleCombiners.foreach(elem => assertSkip(elem._1, elem._2))
+
+  def assertSkip(combiner: DLLCombinerTest, array: Array[Int]): Unit =
+    build(combiner, array)
+    combiner.result()
+    assertEquals(
+      combiner.nonSkipped,
+      false, {
+        s"ERROR: Calls to 'next' and 'previous' are not allowed! You should only use 'next2` and 'previous2' in your solution."
+      }
+    )
+
+  // (25+15+25+50+) 75 / 250 points for correct parallel implementation, exactly 1/4 of the array per task
+  test("[TaskFairness] each task should compute 1/4 of the result (15pts)") {
+    assertTaskFairness(simpleCombiners.unzip._1)
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - simple combiners (15pts)"
+  ) {
+    assertTaskFairness(simpleCombiners.unzip._1)
+    assertCorrectnessSimple()
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners (15pts)"
+  ) {
+    assertTaskFairness(basicCombiners.unzip._1)
+    assertCorrectnessBasic()
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners after combining (15pts)"
+  ) {
+    assertTaskFairness(combinedCombiners.unzip._1)
+    assertCorrectnessCombined()
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - large combiners (15pts)"
+  ) {
+    assertTaskFairness(largeCombiners.unzip._1)
+    assertCorrectnessLarge()
+  }
+
+  def assertTaskFairness(combiners: List[DLLCombiner]): Unit =
+    def assertNewTaskFairness(combiner: DLLCombiner, task: ForkJoinTask[Unit], data: Array[Int]) =
+      var count = 0
+      var expected = combiner.size / 4
+      task.join
+      count = data.count(elem => elem != 0)
+      assert((count - expected).abs <= 1)
+
+    def assertMainTaskFairness(combiner: DLLCombiner, task: Unit, data: Array[Int]) =
+      var count = 0
+      var expected = combiner.size / 4
+      count = data.count(elem => elem != 0)
+      assert((count - expected).abs <= 1)
+
+    combiners.foreach { elem =>
+      var data = Array.fill(elem.size)(0)
+      assertNewTaskFairness(elem, elem.task1(data), data)
+
+      data = Array.fill(elem.size)(0)
+      assertNewTaskFairness(elem, elem.task2(data), data)
+
+      data = Array.fill(elem.size)(0)
+      assertNewTaskFairness(elem, elem.task3(data), data)
+
+      data = Array.fill(elem.size)(0)
+      assertMainTaskFairness(elem, elem.task4(data), data)
+    }
+
+  // (25+15+25+50+75+) 60 / 250 points for correct parallel implementation, exactly 1/4 of the array per task, exactly the specified quarter
+
+  test(
+    "[TaskPrecision] each task should compute specified 1/4 of the result - simple combiners (10pts)"
+  ) {
+    assertTaskPrecision(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task1 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision1(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task2 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision2(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task3 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision3(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task4 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision4(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] each task should compute specified 1/4 of the result - other combiners (30pts)"
+  ) {
+    assertTaskPrecision(basicCombiners)
+    assertTaskPrecision(combinedCombiners)
+    assertTaskPrecision(largeCombiners)
+  }
+
+  def assertTaskPrecision(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    assertTaskPrecision1(combiners)
+    assertTaskPrecision2(combiners)
+    assertTaskPrecision3(combiners)
+    assertTaskPrecision4(combiners)
+
+  def assertTaskPrecision1(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem =>
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task1 = elem._1.task1(data)
+      task1.join
+      Range(0, elem._1.size).foreach(i =>
+        (if i < elem._1.size / 2 - 1 && i % 2 == 0 then ref(i) = elem._2(i))
+      )
+      assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i)))
+    }
+
+  def assertTaskPrecision2(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem =>  
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task2 = elem._1.task2(data)
+      task2.join
+      Range(0, elem._1.size).foreach(i =>
+        (if i < elem._1.size / 2 - 1 && i % 2 == 1 then ref(i) = elem._2(i))
+      )
+      assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i)))
+  }
+
+  def assertTaskPrecision3(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem => 
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task3 = elem._1.task3(data)
+      task3.join
+      Range(0, elem._1.size).foreach(i =>
+        (if i > elem._1.size / 2 + 1 && i % 2 == elem._1.size % 2 then ref(i) = elem._2(i))
+      )
+      assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i)))
+    }
+
+  def assertTaskPrecision4(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem =>  
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task4 = elem._1.task4(data)
+      Range(0, elem._1.size).foreach(i =>
+        (if i > elem._1.size / 2 + 1 && i % 2 != elem._1.size % 2 then ref(i) = elem._2(i))
+      )
+      assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i)))
+    }
+
+trait AbstractProblem1Suite extends munit.FunSuite with LibImpl:
+
+  def simpleCombiners = buildSimpleCombiners()
+  def basicCombiners = buildBasicCombiners()
+  def combinedCombiners = buildCombinedCombiners()
+  def largeCombiners = buildLargeCombiners()
+
+  def buildSimpleCombiners() =
+    val simpleCombiners = List(
+      (new DLLCombinerTest, Array(4, 2, 6, 1, 5, 4, 3, 5, 6, 3, 4, 5, 6, 3, 4, 5)),
+      (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 1, 1, 1, 1, 1, 1, 1, 2, 3, 2)),
+      (new DLLCombinerTest, Array.fill(16)(5))
+    )
+    simpleCombiners.foreach(elem => build(elem._1, elem._2))
+    simpleCombiners
+
+  def buildBasicCombiners() =
+    val basicCombiners = List(
+      (new DLLCombinerTest, Array(4, 2, 6)),
+      (new DLLCombinerTest, Array(4, 1, 6)),
+      (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 11, 12, 5, 14, 15, 1, 17, 23)),
+      (new DLLCombinerTest, Array(7, 2, 9, 9, 3, 2, 11, 12, 13, 14, 15, 16, 17, 22)),
+      (new DLLCombinerTest, Array.fill(16)(7)),
+      (new DLLCombinerTest, Array.fill(16)(4)),
+      (new DLLCombinerTest, Array.fill(5)(3)),
+      (new DLLCombinerTest, Array.fill(5)(7)),
+      (new DLLCombinerTest, Array.fill(5)(4))
+    )
+    basicCombiners.foreach(elem => build(elem._1, elem._2))
+    basicCombiners
+
+  def buildCombinedCombiners() =
+    var combinedCombiners = List[(DLLCombiner, Array[Int])]()
+
+    Range(1, 10).foreach { n =>
+      val array = basicCombiners.filter(elem => elem._1.size == n).foldLeft(Array[Int]()) {
+        (acc, i) => acc ++ i._2
+      }
+      val empty: DLLCombiner = new DLLCombinerTest
+      val combiner = basicCombiners.filter(elem => elem._1.size == n).map(_._1).foldLeft(empty) {
+        (acc, c) => acc.combine(c)
+      }
+
+      combinedCombiners = combinedCombiners :+ (combiner, array)
+    }
+    combinedCombiners
+
+  def buildLargeCombiners() =
+    val largeCombiners = List(
+      (new DLLCombinerTest, Array.fill(1321)(4) ++ Array.fill(1322)(7)),
+      (new DLLCombinerTest, Array.fill(1341)(2) ++ Array.fill(1122)(5)),
+      (
+        new DLLCombinerTest,
+        Array.fill(1321)(4) ++ Array.fill(1322)(7) ++ Array.fill(321)(4) ++ Array.fill(322)(7)
+      ),
+      (new DLLCombinerTest, Array.fill(992321)(4) ++ Array.fill(99322)(7)),
+      (new DLLCombinerTest, Array.fill(953211)(4) ++ Array.fill(999322)(1))
+    )
+    largeCombiners.foreach(elem => build(elem._1, elem._2))
+    largeCombiners
+
+  def build(combiner: DLLCombinerTest, array: Array[Int]): DLLCombinerTest =
+    array.foreach(elem => combiner += elem)
+    combiner
+
+  def compare(combiner: DLLCombiner, array: Array[Int]): Boolean =
+    val result = combiner.result()
+    Range(0, array.size).forall(i => array(i) == result(i))
+
+  def buildAndCompare(combiner: DLLCombinerTest, array: Array[Int]): Boolean =
+    array.foreach(elem => combiner += elem)
+    val result = combiner.result()
+    Range(0, array.size).forall(i => array(i) == result(i))
+
+trait LibImpl extends Problem1:
+
+  val forkJoinPool = new ForkJoinPool
+
+  abstract class TaskScheduler:
+    def schedule[T](body: => T): ForkJoinTask[T]
+
+  class DefaultTaskScheduler extends TaskScheduler:
+    def schedule[T](body: => T): ForkJoinTask[T] =
+      val t = new RecursiveTask[T]:
+        def compute = body
+      Thread.currentThread match
+        case wt: ForkJoinWorkerThread =>
+          t.fork()
+        case _ =>
+          forkJoinPool.execute(t)
+      t
+
+  val scheduler = new DynamicVariable[TaskScheduler](new DefaultTaskScheduler)
+
+  def task[T](body: => T): ForkJoinTask[T] = scheduler.value.schedule(body)
+
+  class NodeTest(val v: Int, val myCombiner: DLLCombinerTest) extends Node(v):
+    override def getNext: Node =
+      myCombiner.nonSkipped = true
+      next
+    override def getNext2: Node = next2
+    override def getPrevious: Node =
+      myCombiner.nonSkipped = true
+      previous
+    override def getPrevious2: Node = previous2
+    override def setNext(n: Node): Unit = next = n
+    override def setNext2(n: Node): Unit = next2 = n
+    override def setPrevious(n: Node): Unit = previous = n
+    override def setPrevious2(n: Node): Unit = previous2 = n
+
+  class DLLCombinerTest extends DLLCombinerImplementation:
+    var nonSkipped = false
+    override def result(): Array[Int] =
+      nonSkipped = false
+      super.result()
+    override def +=(elem: Int): Unit =
+      val node = new NodeTest(elem, this)
+      if size == 0 then
+        first = node
+        last = node
+        size = 1
+      else
+        last.setNext(node)
+        node.setPrevious(last)
+        node.setPrevious2(last.getPrevious)
+        if size > 1 then last.getPrevious.setNext2(node)
+        else second = node
+        secondToLast = last
+        last = node
+        size += 1
+    override def combine(that: DLLCombiner): DLLCombiner =
+      if this.size == 0 then that
+      else if that.size == 0 then this
+      else
+        this.last.setNext(that.first)
+        this.last.setNext2(that.first.getNext)
+        if this.last.getPrevious != null then
+          this.last.getPrevious.setNext2(that.first) // important
+
+        that.first.setPrevious(this.last)
+        that.first.setPrevious2(this.last.getPrevious)
+        if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important
+
+        if this.size == 1 then second = that.first
+
+        this.size = this.size + that.size
+        this.last = that.last
+        this.secondToLast = that.secondToLast
+
+        this
diff --git a/previous-exams/2022-final-solutions/concpar22final02/.gitignore b/previous-exams/2022-final-solutions/concpar22final02/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final-solutions/concpar22final02/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final02/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final-solutions/concpar22final02/build.sbt b/previous-exams/2022-final-solutions/concpar22final02/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..61e5a6e3108affe1ca7212be2fdddb0f0a38d878
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/build.sbt
@@ -0,0 +1,11 @@
+course := "concpar"
+assignment := "concpar22final02"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "0.7.26" % Test
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/build.properties b/previous-exams/2022-final-solutions/concpar22final02/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala
new file mode 100644
index 0000000000000000000000000000000000000000..decdbc9b912db765987efeb8cb78b554156829d5
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala
@@ -0,0 +1,11 @@
+package concpar22final02
+
+import instrumentation.Monitor
+
+abstract class AbstractBarrier(val numThreads: Int) extends Monitor:
+
+  var count = numThreads
+
+  def awaitZero(): Unit
+
+  def countDown(): Unit
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala
new file mode 100644
index 0000000000000000000000000000000000000000..68066d2d582c11929f8c59a2d8c1cfb6c370b360
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala
@@ -0,0 +1,16 @@
+package concpar22final02
+
+class Barrier(numThreads: Int) extends AbstractBarrier(numThreads):
+
+
+  def awaitZero(): Unit =
+    synchronized {
+      while count > 0 do wait()
+    }
+
+
+  def countDown(): Unit =
+    synchronized {
+      count -= 1
+      if count <= 0 then notifyAll()
+    }
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0f538ea513108b54c4a74bed76cd1828790f6e95
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala
@@ -0,0 +1,47 @@
+package concpar22final02
+
+import scala.collection.mutable.ArrayBuffer
+
+class ImageLib(size: Int):
+
+  val buffer1: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(1)
+  val buffer2: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(0)
+
+  enum Filter(val kernel: Array[Array[Int]]):
+    case Outline extends Filter(Array(Array(-1, -1, -1), Array(-1, 8, -1), Array(-1, -1, -1)))
+    case Sharpen extends Filter(Array(Array(0, -1, 0), Array(-1, 5, -1), Array(0, -1, 0)))
+    case Emboss extends Filter(Array(Array(-2, -1, 0), Array(-1, 1, 1), Array(0, 1, 2)))
+    case Identity extends Filter(Array(Array(0, 0, 0), Array(0, 1, 0), Array(0, 0, 0)))
+
+  def init(input: ArrayBuffer[ArrayBuffer[Int]]) =
+    for i <- 0 to size - 1 do
+      for j <- 0 to size - 1 do
+        buffer1(i)(j) = input(i)(j)
+
+  def computeConvolution(
+      kernel: Array[Array[Int]],
+      input: ArrayBuffer[ArrayBuffer[Int]],
+      row: Int,
+      column: Int
+  ): Int =
+
+    val displacement = Array(-1, 0, 1)
+    var output = 0
+
+    for i <- 0 to 2 do
+      for j <- 0 to 2 do
+        val newI = row + displacement(i)
+        val newJ = column + displacement(j)
+        if newI < 0 || newI >= size || newJ < 0 || newJ >= size then output += 0
+        else output += (kernel(i)(j) * input(newI)(newJ))
+
+    output
+
+  def applyFilter(
+      kernel: Array[Array[Int]],
+      input: ArrayBuffer[ArrayBuffer[Int]],
+      output: ArrayBuffer[ArrayBuffer[Int]],
+      row: Int
+  ): Unit =
+    for i <- 0 to input(row).size - 1 do
+      output(row)(i) = computeConvolution(kernel, input, row, i)
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala
new file mode 100644
index 0000000000000000000000000000000000000000..84e63b5347b948eb9b15665fae9892fc656d2f73
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala
@@ -0,0 +1,29 @@
+package concpar22final02
+
+import java.util.concurrent.atomic.AtomicInteger
+import scala.collection.mutable.ArrayBuffer
+
+class Problem2(imageSize: Int, numThreads: Int, numFilters: Int):
+
+  val barrier: ArrayBuffer[Barrier] = ArrayBuffer.fill(numFilters)(Barrier(numThreads))
+
+  val imageLib: ImageLib = ImageLib(imageSize)
+
+
+  def imagePipeline(
+      filters: Array[imageLib.Filter],
+      rows: Array[Int]
+  ): ArrayBuffer[ArrayBuffer[Int]] =
+    for i <- 0 to filters.size - 1 do
+      for j <- 0 to rows.size - 1 do
+        if i % 2 == 0 then
+          imageLib.applyFilter(filters(i).kernel, imageLib.buffer1, imageLib.buffer2, rows(j))
+        else
+          imageLib.applyFilter(filters(i).kernel, imageLib.buffer2, imageLib.buffer1, rows(j))
+
+      barrier(i).countDown()
+      barrier(i).awaitZero()
+
+    if filters.size % 2 == 0 then imageLib.buffer1
+    else imageLib.buffer2
+
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala
new file mode 100644
index 0000000000000000000000000000000000000000..27183378d9af236e05a04baca4b9311d87cc13a2
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala
@@ -0,0 +1,32 @@
+package concpar22final02.instrumentation
+
+class Dummy
+
+trait Monitor:
+  implicit val dummy: Dummy = new Dummy
+
+  def wait()(implicit i: Dummy) = waitDefault()
+
+  def synchronized[T](e: => T)(implicit i: Dummy) = synchronizedDefault(e)
+
+  def notify()(implicit i: Dummy) = notifyDefault()
+
+  def notifyAll()(implicit i: Dummy) = notifyAllDefault()
+
+  private val lock = new AnyRef
+
+  // Can be overriden.
+  def waitDefault(): Unit = lock.wait()
+  def synchronizedDefault[T](toExecute: => T): T = lock.synchronized(toExecute)
+  def notifyDefault(): Unit = lock.notify()
+  def notifyAllDefault(): Unit = lock.notifyAll()
+
+trait LockFreeMonitor extends Monitor:
+  override def waitDefault() =
+    throw new Exception("Please use lock-free structures and do not use wait()")
+  override def synchronizedDefault[T](toExecute: => T): T =
+    throw new Exception("Please use lock-free structures and do not use synchronized()")
+  override def notifyDefault() =
+    throw new Exception("Please use lock-free structures and do not use notify()")
+  override def notifyAllDefault() =
+    throw new Exception("Please use lock-free structures and do not use notifyAll()")
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala
new file mode 100644
index 0000000000000000000000000000000000000000..fb4a31e20d3d02f8f81f24a012f3fb488f60168a
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala
@@ -0,0 +1,19 @@
+/* Copyright 2009-2015 EPFL, Lausanne */
+package concpar22final02.instrumentation
+
+import java.lang.management.*
+
+/** A collection of methods that can be used to collect run-time statistics about Leon programs.
+  * This is mostly used to test the resources properties of Leon programs
+  */
+object Stats:
+  def timed[T](code: => T)(cont: Long => Unit): T =
+    var t1 = System.currentTimeMillis()
+    val r = code
+    cont((System.currentTimeMillis() - t1))
+    r
+
+  def withTime[T](code: => T): (T, Long) =
+    var t1 = System.currentTimeMillis()
+    val r = code
+    (r, (System.currentTimeMillis() - t1))
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..95da2fc36f3337be15a05a65ed416dc43e50e622
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala
@@ -0,0 +1,413 @@
+package concpar22final02
+
+import scala.concurrent.*
+import scala.concurrent.duration.*
+import scala.collection.mutable.HashMap
+import scala.util.Random
+import instrumentation.SchedulableProblem2
+
+import instrumentation.TestHelper.*
+import instrumentation.TestUtils.*
+import scala.collection.mutable.ArrayBuffer
+
+class Problem2Suite extends munit.FunSuite:
+
+  val imageSize = 5
+  val nThreads = 3
+
+  def rowsForThread(threadNumber: Int): Array[Int] =
+    val start: Int = (imageSize * threadNumber) / nThreads
+    val end: Int = (imageSize * (threadNumber + 1)) / nThreads
+    (start until end).toArray
+
+  test("Should work when barrier is called by a single thread (10pts)") {
+    testManySchedules(
+      1,
+      sched =>
+        val temp = new Problem2(imageSize, 1, 1)
+        (
+          List(() => temp.barrier(0).countDown()),
+          results =>
+            if sched.notifyCount == 0 && sched.notifyAllCount == 0 then
+              val notifyCount = sched.notifyCount
+              val notifyAllCount = sched.notifyAllCount
+              (false, s"No notify call $notifyCount $notifyAllCount")
+            else if temp.barrier(0).count != 0 then
+              val count = temp.barrier(0).count
+              (false, s"Barrier count not equal to zero: $count")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when a single thread processes a single filter (10pts)") {
+    val temp = new Problem2(imageSize, 1, 1)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(Array(temp.imageLib.Filter.Outline), Array(0, 1, 2, 3, 4))
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(-2, -3, -3, -3, -2),
+        ArrayBuffer(3, 0, 0, 0, 3),
+        ArrayBuffer(6, 0, 0, 0, 6),
+        ArrayBuffer(9, 0, 0, 0, 9),
+        ArrayBuffer(22, 15, 15, 15, 22)
+      )
+    )
+  }
+
+  test("Should work when a single thread processes a 2 same filters (15pts)") {
+    val temp = new Problem2(imageSize, 1, 2)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Identity),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+  }
+
+  test("Should work when a single thread processes a 2 different filters (15pts)") {
+    val temp = new Problem2(imageSize, 1, 2)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Outline),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(-2, -3, -3, -3, -2),
+        ArrayBuffer(3, 0, 0, 0, 3),
+        ArrayBuffer(6, 0, 0, 0, 6),
+        ArrayBuffer(9, 0, 0, 0, 9),
+        ArrayBuffer(22, 15, 15, 15, 22)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+  }
+
+  test("Should work when barrier is called by two threads (25pts)") {
+    testManySchedules(
+      2,
+      sched =>
+        val temp = new Problem2(imageSize, 2, 1)
+        (
+          List(
+            () =>
+              temp.barrier(0).countDown()
+              temp.barrier(0).awaitZero()
+            ,
+            () =>
+              temp.barrier(0).countDown()
+              temp.barrier(0).awaitZero()
+          ),
+          results =>
+            if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call")
+            else if sched.waitCount == 0 then (false, s"No wait call")
+            else if temp.barrier(0).count != 0 then
+              val count = temp.barrier(0).count
+              (false, s"Barrier count not equal to zero: $count")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when barrier is called by multiple threads (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new Problem2(imageSize, nThreads, 1)
+        (
+          (for i <- 0 until nThreads yield () =>
+            temp.barrier(0).countDown()
+            temp.barrier(0).awaitZero()
+          ).toList,
+          results =>
+            if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call")
+            else if sched.waitCount == 0 then (false, s"No wait call")
+            else if temp.barrier(0).count != 0 then
+              val count = temp.barrier(0).count
+              (false, s"Barrier count not equal to zero: $count")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when a single thread processes a multiple same filters (25pts)") {
+    val temp = new Problem2(imageSize, 1, 3)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(
+        temp.imageLib.Filter.Outline,
+        temp.imageLib.Filter.Outline,
+        temp.imageLib.Filter.Outline
+      ),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(-128, -173, -107, -173, -128),
+        ArrayBuffer(205, -2, 172, -2, 205),
+        ArrayBuffer(322, -128, 208, -128, 322),
+        ArrayBuffer(55, -854, -428, -854, 55),
+        ArrayBuffer(1180, 433, 751, 433, 1180)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(-16, -22, -18, -22, -16),
+        ArrayBuffer(23, -1, 9, -1, 23),
+        ArrayBuffer(36, -18, 0, -18, 36),
+        ArrayBuffer(29, -67, -45, -67, 29),
+        ArrayBuffer(152, 74, 90, 74, 152)
+      )
+    )
+  }
+
+  test("Should work when a single thread processes multiple filters (25pts)") {
+    val temp = new Problem2(imageSize, 1, 3)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(
+        temp.imageLib.Filter.Identity,
+        temp.imageLib.Filter.Outline,
+        temp.imageLib.Filter.Sharpen
+      ),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(-2, -3, -3, -3, -2),
+        ArrayBuffer(3, 0, 0, 0, 3),
+        ArrayBuffer(6, 0, 0, 0, 6),
+        ArrayBuffer(9, 0, 0, 0, 9),
+        ArrayBuffer(22, 15, 15, 15, 22)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(-10, -10, -9, -10, -10),
+        ArrayBuffer(11, 0, 3, 0, 11),
+        ArrayBuffer(18, -6, 0, -6, 18),
+        ArrayBuffer(17, -24, -15, -24, 17),
+        ArrayBuffer(86, 38, 45, 38, 86)
+      )
+    )
+  }
+
+  test("Should work when multiple thread processes a single filter (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 1)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(Array(temp.imageLib.Filter.Outline), rowsForThread(i))).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(5, 3, 3, 3, 5),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(5, 3, 3, 3, 5)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when multiple thread processes two filters (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 2)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(
+              Array(temp.imageLib.Filter.Outline, temp.imageLib.Filter.Sharpen),
+              rowsForThread(i)
+            )).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(19, 7, 9, 7, 19),
+              ArrayBuffer(7, -6, -3, -6, 7),
+              ArrayBuffer(9, -3, 0, -3, 9),
+              ArrayBuffer(7, -6, -3, -6, 7),
+              ArrayBuffer(19, 7, 9, 7, 19)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(5, 3, 3, 3, 5),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(5, 3, 3, 3, 5)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when multiple thread processes multiple same filters (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4)
+        val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+        for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+        temp.imageLib.init(buf)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(
+              Array(
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Identity
+              ),
+              rowsForThread(i)
+            )).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(0, 0, 0, 0, 0),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(2, 2, 2, 2, 2),
+              ArrayBuffer(3, 3, 3, 3, 3),
+              ArrayBuffer(4, 4, 4, 4, 4)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(0, 0, 0, 0, 0),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(2, 2, 2, 2, 2),
+              ArrayBuffer(3, 3, 3, 3, 3),
+              ArrayBuffer(4, 4, 4, 4, 4)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when multiple thread processes multiple different filters (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4)
+        val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+        for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+        temp.imageLib.init(buf)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(
+              Array(
+                temp.imageLib.Filter.Outline,
+                temp.imageLib.Filter.Sharpen,
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Sharpen
+              ),
+              rowsForThread(i)
+            )).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(-51, -31, -28, -31, -51),
+              ArrayBuffer(47, 2, 24, 2, 47),
+              ArrayBuffer(68, -24, 24, -24, 68),
+              ArrayBuffer(5, -154, -72, -154, 5),
+              ArrayBuffer(375, 83, 164, 83, 375)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(-10, -10, -9, -10, -10),
+              ArrayBuffer(11, 0, 3, 0, 11),
+              ArrayBuffer(18, -6, 0, -6, 18),
+              ArrayBuffer(17, -24, -15, -24, 17),
+              ArrayBuffer(86, 38, 45, 38, 86)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala
new file mode 100644
index 0000000000000000000000000000000000000000..645f9cb4a0398faf65a1bbf798a2d91207f7b38a
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala
@@ -0,0 +1,57 @@
+package concpar22final02.instrumentation
+
+trait MockedMonitor extends Monitor:
+  def scheduler: Scheduler
+
+  // Can be overriden.
+  override def waitDefault() =
+    scheduler.log("wait")
+    scheduler.waitCount.incrementAndGet()
+    scheduler updateThreadState Wait(this, scheduler.threadLocks.tail)
+  override def synchronizedDefault[T](toExecute: => T): T =
+    scheduler.log("synchronized check")
+    val prevLocks = scheduler.threadLocks
+    scheduler updateThreadState Sync(
+      this,
+      prevLocks
+    ) // If this belongs to prevLocks, should just continue.
+    scheduler.log("synchronized -> enter")
+    try toExecute
+    finally
+      scheduler updateThreadState Running(prevLocks)
+      scheduler.log("synchronized -> out")
+  override def notifyDefault() =
+    scheduler mapOtherStates { state =>
+      state match
+        case Wait(lockToAquire, locks) if lockToAquire == this => SyncUnique(this, state.locks)
+        case e                                                 => e
+    }
+    scheduler.notifyCount.incrementAndGet()
+    scheduler.log("notify")
+  override def notifyAllDefault() =
+    scheduler mapOtherStates { state =>
+      state match
+        case Wait(lockToAquire, locks) if lockToAquire == this       => Sync(this, state.locks)
+        case SyncUnique(lockToAquire, locks) if lockToAquire == this => Sync(this, state.locks)
+        case e                                                       => e
+    }
+    scheduler.notifyAllCount.incrementAndGet()
+    scheduler.log("notifyAll")
+
+abstract class ThreadState:
+  def locks: Seq[AnyRef]
+trait CanContinueIfAcquiresLock extends ThreadState:
+  def lockToAquire: AnyRef
+case object Start extends ThreadState:
+  def locks: Seq[AnyRef] = Seq.empty
+case object End extends ThreadState:
+  def locks: Seq[AnyRef] = Seq.empty
+case class Wait(lockToAquire: AnyRef, locks: Seq[AnyRef]) extends ThreadState
+case class SyncUnique(lockToAquire: AnyRef, locks: Seq[AnyRef])
+    extends ThreadState
+    with CanContinueIfAcquiresLock
+case class Sync(lockToAquire: AnyRef, locks: Seq[AnyRef])
+    extends ThreadState
+    with CanContinueIfAcquiresLock
+case class Running(locks: Seq[AnyRef]) extends ThreadState
+case class VariableReadWrite(locks: Seq[AnyRef]) extends ThreadState
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a14587b96699a005681ed720427fb29fae554b64
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala
@@ -0,0 +1,20 @@
+package concpar22final02.instrumentation
+
+import scala.annotation.tailrec
+import concpar22final02.*
+import scala.collection.mutable.ArrayBuffer
+
+class SchedulableBarrier(val scheduler: Scheduler, size: Int)
+    extends Barrier(size)
+    with MockedMonitor
+
+class SchedulableProblem2(
+    val scheduler: Scheduler,
+    imageSize: Int,
+    threadCount: Int,
+    numFilters: Int
+) extends Problem2(imageSize, threadCount, numFilters):
+  self =>
+
+  override val barrier =
+    ArrayBuffer.fill(numFilters)(SchedulableBarrier(scheduler, threadCount))
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala
new file mode 100644
index 0000000000000000000000000000000000000000..4001ee336eb0d42d7b687eac3d972709c8976c18
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala
@@ -0,0 +1,294 @@
+package concpar22final02.instrumentation
+
+import java.util.concurrent.*
+import scala.concurrent.duration.*
+import scala.collection.mutable.*
+import Stats.*
+
+import java.util.concurrent.atomic.AtomicInteger
+
+sealed abstract class Result
+case class RetVal(rets: List[Any]) extends Result
+case class Except(msg: String, stackTrace: Array[StackTraceElement]) extends Result
+case class Timeout(msg: String) extends Result
+
+/** A class that maintains schedule and a set of thread ids. The schedules are advanced after an
+  * operation of a SchedulableBuffer is performed. Note: the real schedule that is executed may
+  * deviate from the input schedule due to the adjustments that had to be made for locks
+  */
+class Scheduler(sched: List[Int]):
+  val maxOps = 500 // a limit on the maximum number of operations the code is allowed to perform
+
+  var waitCount:AtomicInteger = new AtomicInteger(0)
+  var notifyCount:AtomicInteger = new AtomicInteger(0)
+  var notifyAllCount:AtomicInteger = new AtomicInteger(0)
+  
+  private var schedule = sched
+  var numThreads = 0
+  private val realToFakeThreadId = Map[Long, Int]()
+  private val opLog = ListBuffer[String]() // a mutable list (used for efficient concat)
+  private val threadStates = Map[Int, ThreadState]()
+
+  /** Runs a set of operations in parallel as per the schedule. Each operation may consist of many
+    * primitive operations like reads or writes to shared data structure each of which should be
+    * executed using the function `exec`.
+    * @timeout
+    *   in milliseconds
+    * @return
+    *   true - all threads completed on time, false -some tests timed out.
+    */
+  def runInParallel(timeout: Long, ops: List[() => Any]): Result =
+    numThreads = ops.length
+    val threadRes = Array.fill(numThreads) { None: Any }
+    var exception: Option[(Throwable, Int)] = None
+    val syncObject = new Object()
+    var completed = new AtomicInteger(0)
+    // create threads
+    val threads = ops.zipWithIndex.map { case (op, i) =>
+      new Thread(
+        new Runnable():
+          def run(): Unit =
+            val fakeId = i + 1
+            setThreadId(fakeId)
+            try
+              updateThreadState(Start)
+              val res = op()
+              updateThreadState(End)
+              threadRes(i) = res
+              // notify the main thread if all threads have completed
+              if completed.incrementAndGet() == ops.length then
+                syncObject.synchronized { syncObject.notifyAll() }
+            catch
+              case e: Throwable if exception != None => // do nothing here and silently fail
+              case e: Throwable =>
+                log(s"throw ${e.toString}")
+                exception = Some((e, fakeId))
+                syncObject.synchronized { syncObject.notifyAll() }
+            // println(s"$fakeId: ${e.toString}")
+            // Runtime.getRuntime().halt(0) //exit the JVM and all running threads (no other way to kill other threads)
+      )
+    }
+    // start all threads
+    threads.foreach(_.start())
+    // wait for all threads to complete, or for an exception to be thrown, or for the time out to expire
+    var remTime = timeout
+    syncObject.synchronized {
+      timed {
+        if completed.get() != ops.length then syncObject.wait(timeout) } { time =>
+        remTime -= time
+      }
+    }
+    if exception.isDefined then
+      Except(
+        s"Thread ${exception.get._2} crashed on the following schedule: \n" + opLog.mkString("\n"),
+        exception.get._1.getStackTrace
+      )
+    else if remTime <= 1 then // timeout ? using 1 instead of zero to allow for some errors
+      Timeout(opLog.mkString("\n"))
+    else
+      // every thing executed normally
+      RetVal(threadRes.toList)
+
+  // Updates the state of the current thread
+  def updateThreadState(state: ThreadState): Unit =
+    val tid = threadId
+    synchronized {
+      threadStates(tid) = state
+    }
+    state match
+      case Sync(lockToAquire, locks) =>
+        if locks.indexOf(lockToAquire) < 0 then waitForTurn
+        else
+          // Re-aqcuiring the same lock
+          updateThreadState(Running(lockToAquire +: locks))
+      case Start      => waitStart()
+      case End        => removeFromSchedule(tid)
+      case Running(_) =>
+      case _          => waitForTurn // Wait, SyncUnique, VariableReadWrite
+
+  def waitStart(): Unit =
+    // while (threadStates.size < numThreads) {
+    // Thread.sleep(1)
+    // }
+    synchronized {
+      if threadStates.size < numThreads then wait()
+      else notifyAll()
+    }
+
+  def threadLocks =
+    synchronized {
+      threadStates(threadId).locks
+    }
+
+  def threadState =
+    synchronized {
+      threadStates(threadId)
+    }
+
+  def mapOtherStates(f: ThreadState => ThreadState) =
+    val exception = threadId
+    synchronized {
+      for k <- threadStates.keys if k != exception do threadStates(k) = f(threadStates(k))
+    }
+
+  def log(str: String) =
+    if (realToFakeThreadId contains Thread.currentThread().getId()) then
+      val space = (" " * ((threadId - 1) * 2))
+      val s = space + threadId + ":" + "\n".r.replaceAllIn(str, "\n" + space + "  ")
+      opLog += s
+
+  /** Executes a read or write operation to a global data structure as per the given schedule
+    * @param msg
+    *   a message corresponding to the operation that will be logged
+    */
+  def exec[T](primop: => T)(msg: => String, postMsg: => Option[T => String] = None): T =
+    if !(realToFakeThreadId contains Thread.currentThread().getId()) then primop
+    else
+      updateThreadState(VariableReadWrite(threadLocks))
+      val m = msg
+      if m != "" then log(m)
+      if opLog.size > maxOps then
+        throw new Exception(
+          s"Total number of reads/writes performed by threads exceed $maxOps. A possible deadlock!"
+        )
+      val res = primop
+      postMsg match
+        case Some(m) => log(m(res))
+        case None    =>
+      res
+
+  private def setThreadId(fakeId: Int) = synchronized {
+    realToFakeThreadId(Thread.currentThread.getId) = fakeId
+  }
+
+  def threadId =
+    try realToFakeThreadId(Thread.currentThread().getId())
+    catch
+      case e: NoSuchElementException =>
+        throw new Exception(
+          "You are accessing shared variables in the constructor. This is not allowed. The variables are already initialized!"
+        )
+
+  private def isTurn(tid: Int) = synchronized {
+    (!schedule.isEmpty && schedule.head != tid)
+  }
+
+  def canProceed(): Boolean =
+    val tid = threadId
+    canContinue match
+      case Some((i, state)) if i == tid =>
+        // println(s"$tid: Runs ! Was in state $state")
+        canContinue = None
+        state match
+          case Sync(lockToAquire, locks) => updateThreadState(Running(lockToAquire +: locks))
+          case SyncUnique(lockToAquire, locks) =>
+            mapOtherStates {
+              _ match
+                case SyncUnique(lockToAquire2, locks2) if lockToAquire2 == lockToAquire =>
+                  Wait(lockToAquire2, locks2)
+                case e => e
+            }
+            updateThreadState(Running(lockToAquire +: locks))
+          case VariableReadWrite(locks) => updateThreadState(Running(locks))
+        true
+      case Some((i, state)) =>
+        // println(s"$tid: not my turn but $i !")
+        false
+      case None =>
+        false
+
+  var threadPreference =
+    0 // In the case the schedule is over, which thread should have the preference to execute.
+
+  /** returns true if the thread can continue to execute, and false otherwise */
+  def decide(): Option[(Int, ThreadState)] =
+    if !threadStates.isEmpty
+    then // The last thread who enters the decision loop takes the decision.
+      // println(s"$threadId: I'm taking a decision")
+      if threadStates.values.forall {
+          case e: Wait => true
+          case _       => false
+        }
+      then
+        val waiting = threadStates.keys.map(_.toString).mkString(", ")
+        val s = if threadStates.size > 1 then "s" else ""
+        val are = if threadStates.size > 1 then "are" else "is"
+        throw new Exception(
+          s"Deadlock: Thread$s $waiting $are waiting but all others have ended and cannot notify them."
+        )
+      else
+        // Threads can be in Wait, Sync, SyncUnique, and VariableReadWrite mode.
+        // Let's determine which ones can continue.
+        val notFree = threadStates.collect { case (id, state) => state.locks }.flatten.toSet
+        val threadsNotBlocked = threadStates.toSeq.filter {
+          case (id, v: VariableReadWrite) => true
+          case (id, v: CanContinueIfAcquiresLock) =>
+            !notFree(v.lockToAquire) || (v.locks contains v.lockToAquire)
+          case _ => false
+        }
+        if threadsNotBlocked.isEmpty then
+          val waiting = threadStates.keys.map(_.toString).mkString(", ")
+          val s = if threadStates.size > 1 then "s" else ""
+          val are = if threadStates.size > 1 then "are" else "is"
+          val whoHasLock = threadStates.toSeq.flatMap { case (id, state) =>
+            state.locks.map(lock => (lock, id))
+          }.toMap
+          val reason = threadStates
+            .collect {
+              case (id, state: CanContinueIfAcquiresLock) if !notFree(state.lockToAquire) =>
+                s"Thread $id is waiting on lock ${state.lockToAquire} held by thread ${whoHasLock(state.lockToAquire)}"
+            }
+            .mkString("\n")
+          throw new Exception(s"Deadlock: Thread$s $waiting are interlocked. Indeed:\n$reason")
+        else if threadsNotBlocked.size == 1
+        then // Do not consume the schedule if only one thread can execute.
+          Some(threadsNotBlocked(0))
+        else
+          val next =
+            schedule.indexWhere(t => threadsNotBlocked.exists { case (id, state) => id == t })
+          if next != -1 then
+            // println(s"$threadId: schedule is $schedule, next chosen is ${schedule(next)}")
+            val chosenOne = schedule(next) // TODO: Make schedule a mutable list.
+            schedule = schedule.take(next) ++ schedule.drop(next + 1)
+            Some((chosenOne, threadStates(chosenOne)))
+          else
+            threadPreference = (threadPreference + 1) % threadsNotBlocked.size
+            val chosenOne = threadsNotBlocked(threadPreference) // Maybe another strategy
+            Some(chosenOne)
+            // threadsNotBlocked.indexOf(threadId) >= 0
+            /*
+            val tnb = threadsNotBlocked.map(_._1).mkString(",")
+            val s = if (schedule.isEmpty) "empty" else schedule.mkString(",")
+            val only = if (schedule.isEmpty) "" else " only"
+            throw new Exception(s"The schedule is $s but$only threads ${tnb} can continue")*/
+    else canContinue
+
+  /** This will be called before a schedulable operation begins. This should not use synchronized
+    */
+  var numThreadsWaiting = new AtomicInteger(0)
+  // var waitingForDecision = Map[Int, Option[Int]]() // Mapping from thread ids to a number indicating who is going to make the choice.
+  var canContinue: Option[(Int, ThreadState)] =
+    None // The result of the decision thread Id of the thread authorized to continue.
+  private def waitForTurn =
+    synchronized {
+      if numThreadsWaiting.incrementAndGet() == threadStates.size then
+        canContinue = decide()
+        notifyAll()
+      // waitingForDecision(threadId) = Some(numThreadsWaiting)
+      // println(s"$threadId Entering waiting with ticket number $numThreadsWaiting/${waitingForDecision.size}")
+      while !canProceed() do wait()
+    }
+    numThreadsWaiting.decrementAndGet()
+
+  /** To be invoked when a thread is about to complete
+    */
+  private def removeFromSchedule(fakeid: Int) = synchronized {
+    // println(s"$fakeid: I'm taking a decision because I finished")
+    schedule = schedule.filterNot(_ == fakeid)
+    threadStates -= fakeid
+    if numThreadsWaiting.get() == threadStates.size then
+      canContinue = decide()
+      notifyAll()
+  }
+
+  def getOperationLog() = opLog
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala
new file mode 100644
index 0000000000000000000000000000000000000000..c4bcda003027cee03742303fac8dbcfd209f48db
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala
@@ -0,0 +1,127 @@
+package concpar22final02.instrumentation
+
+import scala.util.Random
+import scala.collection.mutable.{Map as MutableMap}
+
+import Stats.*
+
+object TestHelper:
+  val noOfSchedules = 10000 // set this to 100k during deployment
+  val readWritesPerThread = 20 // maximum number of read/writes possible in one thread
+  val contextSwitchBound = 10
+  val testTimeout = 240 // the total time out for a test in seconds
+  val schedTimeout = 15 // the total time out for execution of a schedule in secs
+
+  // Helpers
+  /*def testManySchedules(op1: => Any): Unit = testManySchedules(List(() => op1))
+  def testManySchedules(op1: => Any, op2: => Any): Unit = testManySchedules(List(() => op1, () => op2))
+  def testManySchedules(op1: => Any, op2: => Any, op3: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3))
+  def testManySchedules(op1: => Any, op2: => Any, op3: => Any, op4: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3, () => op4))*/
+
+  def testSequential[T](ops: Scheduler => Any)(assertions: T => (Boolean, String)) =
+    testManySchedules(
+      1,
+      (sched: Scheduler) =>
+        (List(() => ops(sched)), (res: List[Any]) => assertions(res.head.asInstanceOf[T]))
+    )
+
+  /** @numThreads
+    *   number of threads
+    * @ops
+    *   operations to be executed, one per thread
+    * @assertion
+    *   as condition that will executed after all threads have completed (without exceptions) the
+    *   arguments are the results of the threads
+    */
+  def testManySchedules(
+      numThreads: Int,
+      ops: Scheduler => (
+          List[() => Any], // Threads
+          List[Any] => (Boolean, String)
+      ) // Assertion
+  ) =
+    var timeout = testTimeout * 1000L
+    val threadIds = (1 to numThreads)
+    // (1 to scheduleLength).flatMap(_ => threadIds).toList.permutations.take(noOfSchedules).foreach {
+    val schedules = (new ScheduleGenerator(numThreads)).schedules()
+    var schedsExplored = 0
+    schedules.takeWhile(_ => schedsExplored <= noOfSchedules && timeout > 0).foreach {
+      // case _ if timeout <= 0 => // break
+      case schedule =>
+        schedsExplored += 1
+        val schedr = new Scheduler(schedule)
+        // println("Exploring Sched: "+schedule)
+        val (threadOps, assertion) = ops(schedr)
+        if threadOps.size != numThreads then
+          throw new IllegalStateException(
+            s"Number of threads: $numThreads, do not match operations of threads: $threadOps"
+          )
+        timed { schedr.runInParallel(schedTimeout * 1000, threadOps) } { t => timeout -= t } match
+          case Timeout(msg) =>
+            throw new java.lang.AssertionError(
+              "assertion failed\n" + "The schedule took too long to complete. A possible deadlock! \n" + msg
+            )
+          case Except(msg, stkTrace) =>
+            val traceStr =
+              "Thread Stack trace: \n" + stkTrace.map(" at " + _.toString).mkString("\n")
+            throw new java.lang.AssertionError("assertion failed\n" + msg + "\n" + traceStr)
+          case RetVal(threadRes) =>
+            // check the assertion
+            val (success, custom_msg) = assertion(threadRes)
+            if !success then
+              val msg =
+                "The following schedule resulted in wrong results: \n" + custom_msg + "\n" + schedr
+                  .getOperationLog()
+                  .mkString("\n")
+              throw new java.lang.AssertionError("Assertion failed: " + msg)
+    }
+    if timeout <= 0 then
+      throw new java.lang.AssertionError(
+        "Test took too long to complete! Cannot check all schedules as your code is too slow!"
+      )
+
+  /** A schedule generator that is based on the context bound
+    */
+  class ScheduleGenerator(numThreads: Int):
+    val scheduleLength = readWritesPerThread * numThreads
+    val rands = (1 to scheduleLength).map(i => new Random(0xcafe * i)) // random numbers for choosing a thread at each position
+    def schedules(): LazyList[List[Int]] =
+      var contextSwitches = 0
+      var contexts = List[Int]() // a stack of thread ids in the order of context-switches
+      val remainingOps = MutableMap[Int, Int]()
+      remainingOps ++= (1 to numThreads).map(i =>
+        (i, readWritesPerThread)
+      ) // num ops remaining in each thread
+      val liveThreads = (1 to numThreads).toSeq.toBuffer
+
+      /** Updates remainingOps and liveThreads once a thread is chosen for a position in the
+        * schedule
+        */
+      def updateState(tid: Int): Unit =
+        val remOps = remainingOps(tid)
+        if remOps == 0 then liveThreads -= tid
+        else remainingOps += (tid -> (remOps - 1))
+      val schedule = rands.foldLeft(List[Int]()) {
+        case (acc, r) if contextSwitches < contextSwitchBound =>
+          val tid = liveThreads(r.nextInt(liveThreads.size))
+          contexts match
+            case prev :: tail if prev != tid => // we have a new context switch here
+              contexts +:= tid
+              contextSwitches += 1
+            case prev :: tail =>
+            case _ => // init case
+              contexts +:= tid
+          updateState(tid)
+          acc :+ tid
+        case (
+              acc,
+              _
+            ) => // here context-bound has been reached so complete the schedule without any more context switches
+          if !contexts.isEmpty then contexts = contexts.dropWhile(remainingOps(_) == 0)
+          val tid = contexts match
+            case top :: tail => top
+            case _ => liveThreads(0) // here, there has to be threads that have not even started
+          updateState(tid)
+          acc :+ tid
+      }
+      schedule #:: schedules()
diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala
new file mode 100644
index 0000000000000000000000000000000000000000..5c76ec9ba4020737013b5302b7e3af0e61cc8898
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala
@@ -0,0 +1,14 @@
+package concpar22final02.instrumentation
+
+import scala.concurrent.*
+import scala.concurrent.duration.*
+import scala.concurrent.ExecutionContext.Implicits.global
+
+object TestUtils:
+  def failsOrTimesOut[T](action: => T): Boolean =
+    val asyncAction = Future {
+      action
+    }
+    try Await.result(asyncAction, 2000.millisecond)
+    catch case _: Throwable => return true
+    return false
diff --git a/previous-exams/2022-final-solutions/concpar22final03/.gitignore b/previous-exams/2022-final-solutions/concpar22final03/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final-solutions/concpar22final03/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final03/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final-solutions/concpar22final03/build.sbt b/previous-exams/2022-final-solutions/concpar22final03/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..19eefd46becd98fc415b1c37ed07eb5225b9621e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/build.sbt
@@ -0,0 +1,11 @@
+course := "concpar"
+assignment := "concpar22final03"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/build.properties b/previous-exams/2022-final-solutions/concpar22final03/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala
new file mode 100644
index 0000000000000000000000000000000000000000..c0327140228a4016e257931a3bfd3d2ca863774d
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala
@@ -0,0 +1,44 @@
+package concpar22final03
+
+import scala.concurrent.Future
+
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import scala.util.Random
+
+trait Economics:
+
+  /** A trading card from the game Scala: The Programming. We can own a card, but once don't
+    * anymore.
+    */
+  final class Card(val name: String)
+  def isMine(c: Card): Boolean
+
+  /** This function uses the best available database to return the sell value of a card on the
+    * market.
+    */
+  def valueOf(cardName: String): Int = List(1, cardName.length).max
+
+  /** This method represents an exact amount of money that can be hold, spent, or put in the bank
+    */
+  final class MoneyBag()
+  def moneyIn(m: MoneyBag): Int
+
+  /** If you sell a card, at some point in the future you will get some money (in a bag).
+    */
+  def sellCard(c: Card): Future[MoneyBag]
+
+  /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate
+    * amount and waiting for the transaction to take place. You will own the returned card.
+    */
+  def buyCard(money: MoneyBag, name: String): Future[Card]
+
+  /** This simple bank account holds money for you. You can bring a money bag to increase your
+    * account's balance, or withdraw a money bag of any size not greater than your account's
+    * balance.
+    */
+  def balance: Int
+  def withdraw(amount: Int): Future[MoneyBag]
+  def deposit(bag: MoneyBag): Future[Unit]
+
+  class NotEnoughMoneyException extends Exception("Not enough money provided to buy those cards")
\ No newline at end of file
diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala
new file mode 100644
index 0000000000000000000000000000000000000000..3b7fa1bb5a7d3076726ab1e7aba7d42ad5fde6cb
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala
@@ -0,0 +1,52 @@
+package concpar22final03
+
+import scala.concurrent.Future
+import concurrent.ExecutionContext.Implicits.global
+
+trait Problem3:
+  val economics: Economics
+  import economics.*
+
+  /** The objective is to propose a service of deck building. People come to you with some money and
+    * some cards they want to sell, and you need to return them a complete deck of the cards they
+    * want.
+    */
+  def orderDeck(
+      bag: MoneyBag,
+      cardsToSell: List[Card],
+      wantedDeck: List[String]
+  ): Future[List[Card]] =
+
+    Future {
+      val totalGivenMoney =
+        cardsToSell.foldLeft(moneyIn(bag))((sum, c) => sum + valueOf(c.name))
+      val totalNeededMoney =
+        wantedDeck.foldLeft(0)((sum, n) => sum + valueOf(n))
+      if totalGivenMoney < totalNeededMoney then
+        throw new NotEnoughMoneyException()
+      val soldCards: Future[Unit] =
+        if moneyIn(bag) != 0 then sellListOfCards(cardsToSell).zip(deposit(bag)).map(_ => ())
+        else sellListOfCards(cardsToSell).map(_ => ())
+      soldCards.flatMap { _ => buyListOfCards(wantedDeck) }
+    }.flatten
+
+  /** This helper function will sell the provided list of cards and put the money on your personal
+    * bank account. It returns a Future of Unit, which indicates when all sales are completed.
+    */
+  def sellListOfCards(cardsToSell: List[Card]): Future[Unit] =
+    val moneyFromSales: List[Future[Unit]] = cardsToSell.map { c =>
+      sellCard(c).flatMap(m => deposit(m).map { _ => })
+    }
+    Future
+      .sequence(moneyFromSales)
+      .map(_ => ()) // Future.sequence transforms a List[Future[A]] into a Future[List[A]]
+
+  /** This helper function, given a list of wanted card names and assuming there is enough money in
+    * the bank account, will buy (in the future) those cards, and return them.
+    */
+  def buyListOfCards(wantedDeck: List[String]): Future[List[Card]] =
+
+    val boughtCards: List[Future[Card]] = wantedDeck.map { name =>
+      withdraw(valueOf(name)).flatMap(mb => buyCard(mb, name))
+    }
+    Future.sequence(boughtCards)
diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a8c327e35bbb55159e329e0f048aa49172accd13
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala
@@ -0,0 +1,98 @@
+package concpar22final03
+
+import scala.concurrent.Future
+
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import scala.util.Random
+
+trait EconomicsTest extends Economics:
+  val ownedCards: collection.mutable.Set[Card] = collection.mutable.Set[Card]()
+  def owned(c: Card): Boolean = ownedCards(c)
+  def isMine(c: Card): Boolean = ownedCards(c)
+
+  override def valueOf(cardName: String): Int = List(1, cardName.length).max
+
+  /** This is a container for an exact amount of money that can be hold, spent, or put in the bank
+    */
+  val moneyInMoneyBag = collection.mutable.Map[MoneyBag, Int]()
+  def moneyIn(m: MoneyBag): Int = moneyInMoneyBag.getOrElse(m, 0)
+
+  /** If you sell a card, at some point in the future you will get some money (in a bag).
+    */
+  def sellCard(c: Card): Future[MoneyBag] =
+    Future {
+      Thread.sleep(sellWaitTime())
+      synchronized(
+        if owned(c) then
+          ownedCards.remove(c)
+          getMoneyBag(valueOf(c.name))
+        else
+          throw Exception(
+            "This card doesn't belong to you or has already been sold, you can't sell it."
+          )
+      )
+    }
+
+  /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate
+    * amount and waiting for the transaction to take place
+    */
+  def buyCard(bag: MoneyBag, name: String): Future[Card] =
+    Future {
+      Thread.sleep(buyWaitTime())
+      synchronized {
+        if moneyIn(bag) != valueOf(name) then
+          throw Exception(
+            "You didn't provide the exact amount of money necessary to buy this card."
+          )
+        else moneyInMoneyBag.update(bag, 0)
+        getCard(name)
+      }
+
+    }
+
+  /** This simple bank account hold money for you. You can bring a money bag to increase your
+    * account, or withdraw a money bag of any size not greater than your account's balance.
+    */
+  private var balance_ = initialBalance()
+  def balance: Int = balance_
+  def withdraw(amount: Int): Future[MoneyBag] =
+    Future {
+      Thread.sleep(withdrawWaitTime())
+      synchronized(
+        if balance_ >= amount then
+          balance_ -= amount
+          getMoneyBag(amount)
+        else
+          throw new Exception(
+            "You try to withdraw more money than you have on your account"
+          )
+      )
+    }
+
+  def deposit(bag: MoneyBag): Future[Unit] =
+    Future {
+      Thread.sleep(depositWaitTime())
+      synchronized {
+        if moneyInMoneyBag(bag) == 0 then throw new Exception("You are depositing en empty bag!")
+        else
+          balance_ += moneyIn(bag)
+          moneyInMoneyBag.update(bag, 0)
+      }
+    }
+
+  def sellWaitTime(): Int
+  def buyWaitTime(): Int
+  def withdrawWaitTime(): Int
+  def depositWaitTime(): Int
+  def initialBalance(): Int
+
+  def getMoneyBag(i: Int) =
+    val m = MoneyBag()
+    synchronized(moneyInMoneyBag.update(m, i))
+    m
+
+  def getCard(n: String): Card =
+    val c = Card(n)
+    synchronized(ownedCards.update(c, true))
+    c
diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a99852a24b5e541093beee0a534b98f3547d1aba
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala
@@ -0,0 +1,201 @@
+package concpar22final03
+
+import scala.concurrent.duration.*
+import scala.concurrent.{Await, Future}
+import scala.util.{Try, Success, Failure}
+import scala.concurrent.ExecutionContext.Implicits.global
+
+class Problem3Suite extends munit.FunSuite:
+  trait Prob3Test extends Problem3:
+    override val economics: EconomicsTest
+  class Test1 extends Prob3Test:
+    override val economics: EconomicsTest = new EconomicsTest:
+      override def sellWaitTime() = 10
+      override def buyWaitTime() = 20
+      override def depositWaitTime() = 30
+      override def withdrawWaitTime() = 40
+      override def initialBalance() = 0
+  class Test2 extends Prob3Test:
+    override val economics: EconomicsTest = new EconomicsTest:
+      override def sellWaitTime() = 100
+      override def buyWaitTime() = 5
+      override def depositWaitTime() = 50
+      override def withdrawWaitTime() = 5
+      override def initialBalance() = 0
+
+  class Test3 extends Prob3Test:
+    override val economics: EconomicsTest = new EconomicsTest:
+      val rgen = new scala.util.Random(666)
+      override def sellWaitTime() = rgen.nextInt(100)
+      override def buyWaitTime() = rgen.nextInt(100)
+      override def depositWaitTime() = rgen.nextInt(100)
+      override def withdrawWaitTime() = rgen.nextInt(100)
+      override def initialBalance() = 0
+
+  class Test4 extends Prob3Test:
+    var counter = 5
+    def next(): Int =
+      counter = counter + 5 % 119
+      counter
+    override val economics: EconomicsTest = new EconomicsTest:
+      override def sellWaitTime() = next()
+      override def buyWaitTime() = next()
+      override def depositWaitTime() = next()
+      override def withdrawWaitTime() = next()
+      override def initialBalance() = next()
+
+  def testCases = List(new Test1, new Test2)
+  def unevenTestCases = List(new Test3, new Test4)
+
+  def tot(cards: List[String]): Int =
+    cards.map[Int]((n: String) => n.length).sum
+
+  def testOk(
+      t: Prob3Test,
+      money: Int,
+      sold: List[String],
+      wanted: List[String]
+  ): Unit =
+    import t.*
+    import economics.*
+    val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted)
+    val r = Await.ready(f, 3.seconds).value.get
+    assert(r.isSuccess)
+    r match
+      case Success(d) =>
+        assertEquals(d.map(_.name).sorted, wanted.sorted)
+        assertEquals(d.length, wanted.length)
+        assertEquals(isMine(d.head), true)
+      case Failure(e) => ()
+
+  def testFailure(
+      t: Prob3Test,
+      money: Int,
+      sold: List[String],
+      wanted: List[String]
+  ): Unit =
+    import t.*
+    import economics.*
+    val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted)
+    val r = Await.ready(f, 3.seconds).value.get
+    assert(r.isFailure)
+    r match
+      case Failure(e: NotEnoughMoneyException) => ()
+      case _ => fail("Should have thrown a NotEnoughMoneyException exception, but did not")
+
+  // --- Without sold cards ---
+
+  test(
+    "Should work correctly when a single card is asked with enough money (no card sold) (20pts)"
+  ) {
+    testCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri")))
+  }
+  test(
+    "Should work correctly when a single card is asked with enough money (no card sold, uneven waiting time) (10pts)"
+  ) {
+    unevenTestCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri")))
+  }
+  test(
+    "Should work correctly when multiple cards are asked with enough money (no card sold) (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    testCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+  test(
+    "Should work correctly when multiple cards are asked with enough money (no card sold, uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+  test(
+    "Should work correctly when asked duplicates of cards, with enough money (no card sold) (20pts)"
+  ) {
+    val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd")
+    testCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+  test(
+    "Should work correctly when asked duplicates of cards, with enough money (no card sold, uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd")
+    unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+
+  // --- With sold cards ---
+
+  test(
+    "Should work correctly when a single card is bought and a single of the same price is sold (20pts)"
+  ) {
+    testCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri")))
+  }
+  test(
+    "Should work correctly when a single card is bought and a single of the same price is sold (uneven waiting time) (10pts)"
+  ) {
+    unevenTestCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri")))
+  }
+
+  test(
+    "Should work correctly when multiple cards are asked and multiple of matching values are sold (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777")
+    testCases.foreach(t => testOk(t, 0, sold, cards))
+  }
+  test(
+    "Should work correctly when multiple cards are asked and multiple of matching values are sold (uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777")
+    unevenTestCases.foreach(t => testOk(t, 0, sold, cards))
+  }
+  test(
+    "Should work correctly when multiple cards are asked and multiple of the same total value are sold (20pts)"
+  ) {
+    val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777")
+    assert(tot(sold2) == tot(cards2))
+    testCases.foreach(t => testOk(t, 0, sold2, cards2))
+  }
+  test(
+    "Should work correctly when multiple cards are asked and multiple of the same total value are sold (uneven waiting time) (10pts)"
+  ) {
+    val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777")
+    assert(tot(sold2) == tot(cards2))
+    unevenTestCases.foreach(t => testOk(t, 0, sold2, cards2))
+  }
+
+  test(
+    "Should work correctly when given money and sold cards are sufficient for the wanted cards (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("11111", "2", "33", "44", "5555", "666", "777")
+    val bagMoney = tot(cards) - tot(sold)
+    testCases.foreach(t => testOk(t, bagMoney, sold, cards))
+  }
+  test(
+    "Should work correctly when given money and sold cards are sufficient for the wanted cards (uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("11111", "2", "33", "44", "5555", "666", "777")
+    val bagMoney = tot(cards) - tot(sold)
+    unevenTestCases.foreach(t => testOk(t, bagMoney, sold, cards))
+  }
+
+  // --- Failures ---
+
+  test(
+    "Should return a failure when too little money is provided (no card sold) (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    testCases.foreach(t => testFailure(t, tot(cards) - 1, Nil, cards))
+    testCases.foreach(t => testFailure(t, tot(cards) - 50, Nil, cards))
+  }
+
+  test(
+    "Should return a failure when too little money or sold cards are provided (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("11111", "2", "33", "44", "5555", "666", "777")
+    val bagMoney = tot(cards) - tot(sold)
+    testCases.foreach(t => testFailure(t, bagMoney - 2, sold, cards))
+  }
diff --git a/previous-exams/2022-final-solutions/concpar22final04/.gitignore b/previous-exams/2022-final-solutions/concpar22final04/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final-solutions/concpar22final04/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final04/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final-solutions/concpar22final04/build.sbt b/previous-exams/2022-final-solutions/concpar22final04/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..ea5fb5d30451b2de6e1d8916323a3d4cef4657fe
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/build.sbt
@@ -0,0 +1,23 @@
+course := "concpar"
+assignment := "concpar22final04"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test
+
+val akkaVersion = "2.6.19"
+val logbackVersion = "1.2.11"
+libraryDependencies ++= Seq(
+    "com.typesafe.akka" %% "akka-actor" % akkaVersion,
+    "com.typesafe.akka" %% "akka-testkit" % akkaVersion,
+    // SLF4J backend
+    // See https://doc.akka.io/docs/akka/current/typed/logging.html#slf4j-backend
+    "ch.qos.logback" % "logback-classic" % logbackVersion
+)
+fork := true
+javaOptions ++= Seq("-Dakka.loglevel=Error", "-Dakka.actor.debug.receive=on")
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/build.properties b/previous-exams/2022-final-solutions/concpar22final04/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala b/previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala
new file mode 100644
index 0000000000000000000000000000000000000000..991be1c0053772b60c01bfdca066ca52b040b777
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala
@@ -0,0 +1,220 @@
+package concpar22final04
+
+import akka.actor.*
+import akka.testkit.*
+import java.util.Date
+import akka.event.LoggingReceive
+import akka.pattern.*
+import akka.util.Timeout
+import concurrent.duration.*
+import scala.concurrent.Future
+import scala.concurrent.ExecutionContext
+
+given timeout: Timeout = Timeout(200.millis)
+
+/** Data associated with a song: a unique `id`, a `title` and an `artist`.
+  */
+case class Song(id: Int, title: String, artist: String)
+
+/** An activity in a user's activity feed, representing that `userRef` is
+  * listening to `songId`.
+  */
+case class Activity(userId: String, userName: String, songId: Int)
+
+/** Companion object of the `User` class.
+  */
+object User:
+  /** Messages that can be sent to User actors.
+    */
+  enum Protocol:
+    /** Asks for a user name and id. Should be answered by a Response.Info.
+      */
+    case GetInfo
+
+    /** Asks home page data. Should be answered by a Response.HomepageData.
+      */
+    case GetHomepageData
+
+    /** Like song with id `songId`.
+      */
+    case Like(songId: Int)
+
+    /** Unlike song with id `songId`.
+      */
+    case Unlike(songId: Int)
+
+    /** Adds `subscriber` to the list of subscribers.
+      */
+    case Subscribe(subscriber: ActorRef)
+
+    /** Remove `subscriber` from the list of subscribers.
+      */
+    case Unsubscribe(subscriber: ActorRef)
+
+    /** Adds the activity `activity` to the activity feed. This message will be
+      * sent by the users this user has subscribed to.
+      */
+    case AddActivity(activity: Activity)
+
+    /** Sent when a user starts playing a song with id `songId`. The recipient
+      * should notify all its subscribers to update their activity feeds by
+      * sending them `AddActivity(Activity(...))` messages. No answer is
+      * expected. This message is sent by external actors.
+      */
+    case Play(songId: Int)
+
+    /** Asks for home page text. Should be answered by a Response.HomepageText.
+      */
+    case GetHomepageText
+
+  /** Responses that can be sent back from User actors.
+    */
+  enum Responses:
+    /** Answer to a Protocol.GetInfo message
+      */
+    case Info(id: String, name: String)
+
+    /** Answer to a Protocol.GetHomepageData message
+      */
+    case HomepageData(songIds: List[Int], activities: List[Activity])
+
+    /** Answer to a Protocol.GetHomepageText message
+      */
+    case HomepageText(result: String)
+
+/** The `User` actor, responsible to handle `User.Protocol` messages.
+  */
+class User(id: String, name: String, songsStore: ActorRef) extends Actor:
+  import User.*
+  import User.Protocol.*
+  import User.Responses.*
+  import SongsStore.Protocol.*
+  import SongsStore.Responses.*
+
+  given ExecutionContext = context.system.dispatcher
+
+  /** Liked songs, by reverse date of liking time (the last liked song must
+    * be the first must be the first element of the list). Elements of this
+    * list must be unique: a song can only be liked once. Liking a song
+    * twice should not change the order.
+    */
+  var likedSongs: List[Int] = List()
+
+  /** Users who have subscribed to this users.
+    */
+  var subscribers: Set[ActorRef] = Set()
+
+  /** Activity feed, by reverse date of activity time (the last added
+    * activity must be the first element of the list). Items in this list
+    * should be unique by `userRef`. If a new activity with a `userRef`
+    * already in the list is added, the former should be removed, so that we
+    * always see the latest activity for each user we have subscribed to.
+    */
+  var activityFeed: List[Activity] = List()
+
+
+  /** This actor's behavior. */
+
+  override def receive: Receive = LoggingReceive {
+    case GetInfo =>
+      sender() ! Info(id, name)
+    case GetHomepageData =>
+      sender() ! HomepageData(likedSongs, activityFeed)
+    case Like(songId) if !likedSongs.contains(songId) =>
+      likedSongs = songId :: likedSongs
+    case Unlike(songId) =>
+      likedSongs = likedSongs.filter(_ != songId)
+    case Subscribe(ref: ActorRef) =>
+      subscribers = subscribers + ref
+    case Unsubscribe(ref: ActorRef) =>
+      subscribers = subscribers - ref
+    case AddActivity(activity: Activity) =>
+      activityFeed = activity :: activityFeed.filter(_.userId != activity.userId)
+    case Play(songId) =>
+      subscribers.foreach(_ ! AddActivity(Activity(id, name, songId)))
+    case GetHomepageText =>
+      val likedSongsFuture: Future[Songs] =
+        (songsStore ? GetSongs(likedSongs)).mapTo[Songs]
+      val activitySongsFuture: Future[Songs] =
+        (songsStore ? GetSongs(activityFeed.map(_.songId))).mapTo[Songs]
+      val response: Future[HomepageText] =
+        for
+          likedSongs <- likedSongsFuture;
+          activitySongs <- activitySongsFuture
+        yield HomepageText(f"""
+          |Howdy ${name}!
+          |
+          |Liked Songs:
+          |${likedSongs.songs
+          .map(song => f"* ${song.title} by ${song.artist}")
+          .mkString("\n")}
+          |
+          |Activity Feed:
+          |${activityFeed
+          .zip(activitySongs.songs)
+          .map((activity, song) => f"* ${activity.userName} is listening to ${song.title} by ${song.artist}")
+          .mkString("\n")}""".stripMargin.trim)
+      response.pipeTo(sender())
+  }
+
+/** Objects containing the messages a songs store should handle.
+  */
+object SongsStore:
+  /** Ask information about a list of songs by their ids.
+    */
+  enum Protocol:
+    case GetSongs(songIds: List[Int])
+
+  /** List of `Song` corresponding to the list of IDs given to `GetSongs`.
+    */
+  enum Responses:
+    case Songs(songs: List[Song])
+
+/** A mock implementation of a songs store.
+  */ 
+class MockSongsStore extends Actor:
+  import SongsStore.Protocol.*
+  import SongsStore.Responses.*
+  import SongsStore.*
+
+  val songsDB = Map(
+    1 -> Song(1, "High Hopes", "Pink Floyd"),
+    2 -> Song(2, "Sunny", "Boney M."),
+    3 -> Song(3, "J'irai où tu iras", "Céline Dion & Jean-Jacques Goldman"),
+    4 -> Song(4, "Ce monde est cruel", "Vald"),
+    5 -> Song(5, "Strobe", "deadmau5"),
+    6 -> Song(6, "Désenchantée", "Mylène Farmer"),
+    7 -> Song(7, "Straight Edge", "Minor Threat"),
+    8 -> Song(8, "Hold the line", "TOTO"),
+    9 -> Song(9, "Anarchy in the UK", "Sex Pistols"),
+    10 -> Song(10, "Breakfast in America", "Supertramp")
+  )
+
+  override def receive: Receive = LoggingReceive { case GetSongs(songsIds) =>
+    sender() ! Songs(songsIds.map(songsDB))
+  }
+
+/////////////////////////
+//        DEBUG        //
+/////////////////////////
+
+/** Infrastructure to help debugging. In sbt use `run` to execute this code.
+ *  The TestKit is an actor that can send messages and check the messages it receives (or not).
+ */
+@main def debug() = new TestKit(ActorSystem("DebugSystem")) with ImplicitSender:
+  import User.*
+  import User.Protocol.*
+  import User.Responses.*
+  import SongsStore.Protocol.*
+  import SongsStore.Responses.*
+
+  try
+    val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore")
+    val anita = system.actorOf(Props(User("100", "Anita", songsStore)))
+
+    anita ! Like(6)
+    expectNoMessage() // expects no message is received
+
+    anita ! GetHomepageData
+    expectMsg(HomepageData(List(6), List()))
+  finally shutdown(system)
diff --git a/previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala b/previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..f88d129333e3732434e22c1c1c65acf22d006a3e
--- /dev/null
+++ b/previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala
@@ -0,0 +1,361 @@
+package concpar22final04
+
+import akka.actor.*
+import akka.testkit.*
+import akka.pattern.*
+import akka.util.Timeout
+import concurrent.duration.*
+import User.Protocol.*
+import User.Responses.*
+import SongsStore.Protocol.*
+import SongsStore.Responses.*
+import scala.util.{Try, Success, Failure}
+import com.typesafe.config.ConfigFactory
+import java.util.Date
+import scala.util.Random
+
+class Problem4Suite extends munit.FunSuite:
+//---
+  Random.setSeed(42178263)
+/*+++
+  Random.setSeed(42)
+++*/
+
+  test("after receiving GetInfo, should answer with Info (20pts)") {
+    new MyTestKit:
+      def tests() =
+        ada ! GetInfo
+        expectMsg(Info("1", "Ada"))
+  }
+
+  test("after receiving GetHomepageData, should answer with the correct HomepageData when there is no liked songs and no activity items (30pts)") {
+    new MyTestKit:
+      def tests() =
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(), List()))
+  }
+
+  test("after receiving Like(1), should add 1 to the list of liked songs (20pts)") {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(1), List()))
+  }
+
+  test(
+    "after receiving Like(1) and then Like(2), the list of liked songs should start with List(2, 1) (20pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(2, 1), List()))
+  }
+
+  test(
+    "after receiving Like(1) and then Like(1), song 1 should be in the list of liked songs only once (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(1), List()))
+  }
+
+  test(
+    "after receiving Like(1), Like(2) and then Like(1), the list of liked songs should start with List(2, 1) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(2, 1), List()))
+  }
+
+  test(
+    "after receiving Like(1), Unlike(1) and then Unlike(1), the list of liked songs should not contain song 1 (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(2, 1), List()))
+  }
+
+  test(
+    "after receiving Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (20pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+  }
+
+  test(
+    "after receiving Subscribe(aUser), Subscribe(bUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        val donald = new TestProbe(system)
+        ada ! Subscribe(donald.ref)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+        donald.expectMsg(AddActivity(Activity("1", "Ada", 5)))
+  }
+
+  test(
+    "after receiving Subscribe(aUser), Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser only once (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+        expectNoMessage()
+  }
+
+  test(
+    "after receiving Subscribe(aUser), Unsubscribe(aUser) and then Play(5), should not send AddActivity(Activity(\"1\", 5)) to aUser (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+        ada ! Unsubscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectNoMessage()
+  }
+
+  test(
+    "after receiving AddActivity(Activity(\"1\", 5)), Activity(\"1\", 5) should be in the activity feed (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! AddActivity(Activity("0", "Self", 5))
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(), List(Activity("0", "Self", 5))))
+  }
+
+  test(
+    "after receiving AddActivity(Activity(\"1\", 5)) and AddActivity(Activity(\"1\", 6)), Activity(\"1\", 6) should be in the activity feed and Activity(\"1\", 5) should not (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! AddActivity(Activity("0", "Self", 5))
+        expectNoMessage()
+        ada ! AddActivity(Activity("0", "Self", 6))
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(), List(Activity("0", "Self", 6))))
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with a result containing \"Howdy $name!\" where $name is the user's name (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        val name = Random.alphanumeric.take(5).mkString
+        val randomUser = system.actorOf(Props(classOf[User], "5", name, songsStore), "user-5")
+        randomUser ! GetHomepageText
+        expectMsgClass(classOf[HomepageText]).result.contains(f"Howdy $name!")
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct names of liked songs (1) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(8)
+        expectNoMessage()
+        ada ! Like(3)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(2)
+            .take(4)
+            .mkString("\n")
+            .trim,
+          """
+          |Liked Songs:
+          |* Sunny by Boney M.
+          |* J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+          |* Hold the line by TOTO
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct names of liked songs (2) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(9)
+        expectNoMessage()
+        ada ! Like(7)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(2)
+            .take(3)
+            .mkString("\n")
+            .trim,
+          """
+          |Liked Songs:
+          |* Straight Edge by Minor Threat
+          |* Anarchy in the UK by Sex Pistols
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct activity feed (1) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        bob ! Subscribe(ada)
+        expectNoMessage()
+        carol ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Subscribe(ada)
+        expectNoMessage()
+        bob ! Play(3)
+        expectNoMessage()
+        carol ! Play(8)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(4)
+            .take(10)
+            .mkString("\n")
+            .trim,
+          """
+          |Activity Feed:
+          |* Carol is listening to Hold the line by TOTO
+          |* Bob is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct activity feed (2) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        bob ! Subscribe(ada)
+        expectNoMessage()
+        carol ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Subscribe(ada)
+        expectNoMessage()
+        bob ! Play(9)
+        expectNoMessage()
+        carol ! Play(10)
+        expectNoMessage()
+        donald ! Play(6)
+        expectNoMessage()
+        bob ! Play(7)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(4)
+            .take(10)
+            .mkString("\n")
+            .trim,
+          """
+          |Activity Feed:
+          |* Bob is listening to Straight Edge by Minor Threat
+          |* Donald is listening to Désenchantée by Mylène Farmer
+          |* Carol is listening to Breakfast in America by Supertramp
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct text (full test) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        bob ! Subscribe(ada)
+        expectNoMessage()
+        carol ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Play(3)
+        expectNoMessage()
+        bob ! Play(4)
+        expectNoMessage()
+        carol ! Play(5)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .mkString("\n")
+            .trim,
+          """
+          |Howdy Ada!
+          |
+          |Liked Songs:
+          |* Sunny by Boney M.
+          |* High Hopes by Pink Floyd
+          |
+          |Activity Feed:
+          |* Carol is listening to Strobe by deadmau5
+          |* Bob is listening to Ce monde est cruel by Vald
+          |* Donald is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+          """.stripMargin.trim
+        )
+  }
+
+  abstract class MyTestKit
+      extends TestKit(ActorSystem("TestSystem"))
+      with ImplicitSender:
+    val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore")
+    def makeAda() = system.actorOf(Props(classOf[User], "1", "Ada", songsStore), "user-1")
+    val ada = makeAda()
+    val bob = system.actorOf(Props(classOf[User], "2", "Bob", songsStore), "user-2")
+    val carol = system.actorOf(Props(classOf[User], "3", "Carol", songsStore), "user-3")
+    val donald = system.actorOf(Props(classOf[User], "4", "Donald", songsStore), "user-4")
+    def tests(): Unit
+    try tests()
+    finally shutdown(system)
diff --git a/previous-exams/2022-final/concpar22final01.md b/previous-exams/2022-final/concpar22final01.md
new file mode 100644
index 0000000000000000000000000000000000000000..7b8d10edb0840450839c58cce991cc49b973db49
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01.md
@@ -0,0 +1,102 @@
+# Problem 1: Combiners
+
+## Setup
+
+Use the following commands to make a fresh clone of your repository:
+
+```
+git clone -b concpar22final01 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final01
+```
+
+If you have issues with the IDE, try [reimporting the
+build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting),
+if you still have problems, use `compile` in sbt instead.
+
+## Exercise
+
+In this exercise, you will implement an array Combiner. The Combiner internally uses a double linked list whose nodes also point to their successor's successor and their predecessor's predecessor. Your goal is to complete the implementation of the (simplified) Combiner interface, by implementing the `result` method to compute the result array from this array combiner.
+
+Here you can see the declaration of the `DLLCombiner` class and the related `Node` class definition. Look at the `Lib` trait in the `lib.scala` file to find all definitions of relevant functions and classes.
+
+```scala
+  class Node(val value: Int):
+    protected var next: Node = null      // null for last node.
+    protected var next2: Node = null     // null for last node.
+    protected var previous: Node = null  // null for first node.
+    protected var previous2: Node = null // null for first node.
+
+    def getNext: Node = next // do NOT use in the result method
+    def getNext2: Node = next2
+    def getPrevious: Node = previous // do NOT use in the result method
+    def getPrevious2: Node = previous2
+
+    def setNext(n: Node): Unit = next = n
+    def setNext2(n: Node): Unit = next2 = n
+    def setPrevious(n: Node): Unit = previous = n
+    def setPrevious2(n: Node): Unit = previous2 = n
+
+
+  // Simplified Combiner interface
+  // Implements methods `+=` and `combine`
+  // Abstract methods should be implemented in subclasses
+  abstract class DLLCombiner
+```
+
+`DLLCombiner` class contains the implementation of methods `+=` and `combine`. You should look at them to better understand the structure of this array Combiner, before moving on to solving this exercise. 
+
+Your task in the exercise will be to implement the `result` method of the `DLLCombinerImplementation` class. This method should compute the result array from this array combiner. In your solution, you should **not** use methods `getNext` and `getPrevious`, but only `getNext2` and `getPrevious2`, to reduce the number of moving operations.
+
+According to the Combiner contract, `result` should work in parallel. Implement this method efficiently using 4 parallel tasks, by copying the double linked list to the array from both ends at the same time. Two threads should start from the start of the list and two from the end. In each case, one thread would be responsible for odd indexes and the other for even ones.
+
+Following the description above, your task in the exercise is to:
+
+ 1. Implement the four tasks to copy parts of the resulting array. Each task is responsible for copying one quarter of the array:
+   - `task1` copies every other Integer element of data array, starting from the first (index 0), up to the middle
+   - `task2` copies every other Integer element of data array, starting from the second, up to the middle
+   - `task3` copies every other Integer element of data array, starting from the second to last, up to the middle
+   - `task4` copies every other Integer element of data array, starting from the last, up to the middle
+ 2. Implement the method `result` to compute the result array in parallel using those four tasks.
+
+ Here is one example of the `result` method:
+
+```scala
+    val combiner1 = new DLLCombinerImplementation
+    combiner1 += 7 
+    combiner1 += 2 
+    combiner1 += 4 
+    combiner1 += 3 
+    combiner1 += 9 
+    combiner1 += 5 
+    combiner1 += 1 
+
+    val res1 = combiner1.result() // (7, 2, 4, 3, 9, 5, 1)
+```
+In this example, `task1` was responsible for copying elements at indexes 0 and 2, `task2` for copying the element at index 1, `task3` for copying elements at indexes 5 and 3, and `task4` for copying element at indexes 6 and 4.
+
+Here is another example with combining:
+
+```scala
+    val c1 = new DLLCombinerImplementation
+    c1 += 7 
+    c1 += 2 
+    c1 += 4 
+    c1 += 3 
+    c1 += 9 
+    c1 += 5 
+    c1 += 1 
+
+    val c2 = new DLLCombinerImplementation
+    c2 += 6 
+    c2 += 8 
+    c2 += 5 
+    c2 += 1 
+
+    val c3 = new DLLCombinerImplementation
+    c3 += 1
+
+    c1.combine(c2).combine(c3) 
+    val res = c1.result() // (7, 2, 4, 3, 9, 5, 1, 6, 8, 5, 1, 1)
+```
+
+You can get partial points for solving parts of this exercise.
+In your solution you should only make changes to the `DLLCombinerImplementation` class. You are not allowed to change the file `lib.scala`.
diff --git a/previous-exams/2022-final/concpar22final01/.gitignore b/previous-exams/2022-final/concpar22final01/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final/concpar22final01/assignment.sbt b/previous-exams/2022-final/concpar22final01/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final/concpar22final01/build.sbt b/previous-exams/2022-final/concpar22final01/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..beb0e5c73b7884cfa88403c52a88d365aa94e1fe
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/build.sbt
@@ -0,0 +1,11 @@
+course := "concpar"
+assignment := "concpar22final01"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final/concpar22final01/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final01/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final/concpar22final01/project/build.properties b/previous-exams/2022-final/concpar22final01/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final/concpar22final01/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final01/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final/concpar22final01/project/plugins.sbt b/previous-exams/2022-final/concpar22final01/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala
new file mode 100644
index 0000000000000000000000000000000000000000..70e569c0de648b370e2c2225cf52ab964f8d3a0d
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala
@@ -0,0 +1,31 @@
+package concpar22final01
+
+trait Problem1 extends Lib:
+
+  class DLLCombinerImplementation extends DLLCombiner:
+
+    // Copies every other Integer element of data array, starting from the first (index 0), up to the middle
+    def task1(data: Array[Int]) = task {
+      ???
+    }
+
+    // Copies every other Integer element of data array, starting from the second, up to the middle
+    def task2(data: Array[Int]) = task {
+      ???
+    }
+
+    // Copies every other Integer element of data array, starting from the second to last, up to the middle
+    def task3(data: Array[Int]) = task {
+      ???
+    }
+
+    // Copies every other Integer element of data array, starting from the last, up to the middle
+    // This is executed on the current thread.
+    def task4(data: Array[Int]) =
+      ???
+
+    def result(): Array[Int] =
+      val data = new Array[Int](size)
+      ???
+
+      data
diff --git a/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala
new file mode 100644
index 0000000000000000000000000000000000000000..6d9d6ee079efaafce9a4a0746a159de2848494dd
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala
@@ -0,0 +1,81 @@
+package concpar22final01
+
+import java.util.concurrent.*
+import scala.util.DynamicVariable
+
+trait Lib:
+  class Node(val value: Int):
+    protected var next: Node = null // null for last node.
+    protected var next2: Node = null // null for last node.
+    protected var previous: Node = null // null for first node.
+    protected var previous2: Node = null // null for first node.
+
+    def getNext: Node = next // do NOT use in the result method
+    def getNext2: Node = next2
+    def getPrevious: Node = previous // do NOT use in the result method
+    def getPrevious2: Node = previous2
+
+    def setNext(n: Node): Unit = next = n
+    def setNext2(n: Node): Unit = next2 = n
+    def setPrevious(n: Node): Unit = previous = n
+    def setPrevious2(n: Node): Unit = previous2 = n
+
+  // Simplified Combiner interface
+  // Implements methods += and combine
+  // Abstract methods should be implemented in subclasses
+  abstract class DLLCombiner:
+    var first: Node = null // null for empty lists.
+    var last: Node = null // null for empty lists.
+
+    var second: Node = null // null for empty lists.
+    var secondToLast: Node = null // null for empty lists.
+
+    var size: Int = 0
+
+    // Adds an Integer to this array combiner.
+    def +=(elem: Int): Unit =
+      val node = new Node(elem)
+      if size == 0 then
+        first = node
+        last = node
+        size = 1
+      else
+        last.setNext(node)
+        node.setPrevious(last)
+        node.setPrevious2(last.getPrevious)
+        if size > 1 then last.getPrevious.setNext2(node)
+        else second = node
+        secondToLast = last
+        last = node
+        size += 1
+
+    // Combines this array combiner and another given combiner in constant O(1) complexity.
+    def combine(that: DLLCombiner): DLLCombiner =
+      if this.size == 0 then that
+      else if that.size == 0 then this
+      else
+        this.last.setNext(that.first)
+        this.last.setNext2(that.first.getNext)
+        if this.last.getPrevious != null then
+          this.last.getPrevious.setNext2(that.first) // important
+
+        that.first.setPrevious(this.last)
+        that.first.setPrevious2(this.last.getPrevious)
+        if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important
+
+        if this.size == 1 then second = that.first
+
+        this.size = this.size + that.size
+        this.last = that.last
+        this.secondToLast = that.secondToLast
+
+        this
+
+    def task1(data: Array[Int]): ForkJoinTask[Unit]
+    def task2(data: Array[Int]): ForkJoinTask[Unit]
+    def task3(data: Array[Int]): ForkJoinTask[Unit]
+    def task4(data: Array[Int]): Unit
+
+    def result(): Array[Int]
+
+  def task[T](body: => T): ForkJoinTask[T]
diff --git a/previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala b/previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a65007252c5cebb91bf2b74a37433fcf040fade7
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala
@@ -0,0 +1,491 @@
+package concpar22final01
+
+import java.util.concurrent.*
+import scala.util.DynamicVariable
+
+class Problem1Suite extends AbstractProblem1Suite:
+
+  test("[Public] fetch simple result without combining (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 7
+    combiner1 += 2
+    combiner1 += 3
+    combiner1 += 8
+    combiner1 += 1
+    combiner1 += 2
+    combiner1 += 3
+    combiner1 += 8
+
+    val result = combiner1.result()
+    val array = Array(7, 2, 3, 8, 1, 2, 3, 8)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+  test("[Public] fetch result without combining (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 7
+    combiner1 += 2
+    combiner1 += 3
+    combiner1 += 8
+    combiner1 += 1
+
+    val result = combiner1.result()
+    val array = Array(7, 2, 3, 8, 1)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+  test("[Public] fetch result after simple combining (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 7
+    combiner1 += 2
+
+    val combiner2 = new DLLCombinerTest
+    combiner2 += 3
+    combiner2 += 8
+
+    val combiner3 = new DLLCombinerTest
+    combiner3 += 1
+    combiner3 += 9
+
+    val combiner4 = new DLLCombinerTest
+    combiner4 += 3
+    combiner4 += 2
+
+    val result = combiner1.combine(combiner2).combine(combiner3).combine(combiner4).result()
+    val array = Array(7, 2, 3, 8, 1, 9, 3, 2)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+  test("[Public] fetch result - small combiner (2pts)") {
+    val combiner1 = new DLLCombinerTest
+    combiner1 += 4
+    combiner1 += 2
+    combiner1 += 6
+
+    val result = combiner1.result()
+    val array = Array(4, 2, 6)
+
+    assert(Range(0, array.size).forall(i => array(i) == result(i)))
+  }
+
+
+  // (25+) 15 / 250 points for correct implementation, don't check parallelism
+  test("[Correctness] fetch result - simple combiners (2pts)") {
+    assertCorrectnessSimple()
+  }
+
+  test("[Correctness] fetch result - small combiners (3pts)") {
+    assertCorrectnessBasic()
+  }
+
+  test("[Correctness] fetch result - small combiners after combining (5pts)") {
+    assertCorrectnessCombined()
+  }
+
+  test("[Correctness] fetch result - large combiners (5pts)") {
+    assertCorrectnessLarge()
+  }
+
+  def assertCorrectnessSimple() = simpleCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  def assertCorrectnessBasic() = basicCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  def assertCorrectnessCombined() =
+    combinedCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  def assertCorrectnessLarge() = largeCombiners.foreach(elem => assert(compare(elem._1, elem._2)))
+
+  // (25+15+) 25 / 250 points for correct parallel implementation, don't check if it's exactly 1/4 of the array per task
+  private var count = 0
+  private val expected = 3
+
+  override def task[T](body: => T): ForkJoinTask[T] =
+    count += 1
+    scheduler.value.schedule(body)
+
+  test("[TaskCount] number of newly created tasks should be 3 (5pts)") {
+    assertTaskCountSimple()
+  }
+
+  test("[TaskCount] fetch result and check parallel - simple combiners (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessSimple()
+  }
+
+  test("[TaskCount] fetch result and check parallel - small combiners (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessBasic()
+  }
+
+  test("[TaskCount] fetch result and check parallel - small combiners after combining (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessCombined()
+  }
+
+  test("[TaskCount] fetch result and check parallel - large combiners (5pts)") {
+    assertTaskCountSimple()
+    assertCorrectnessLarge()
+  }
+
+  def assertTaskCountSimple(): Unit =
+    simpleCombiners.foreach(elem => assertTaskCount(elem._1, elem._2))
+
+  def assertTaskCount(combiner: DLLCombinerTest, array: Array[Int]): Unit =
+    try
+      count = 0
+      build(combiner, array)
+      combiner.result()
+      assertEquals(
+        count,
+        expected, {
+          s"ERROR: Expected $expected instead of $count calls to `task(...)`"
+        }
+      )
+    finally count = 0
+
+  // (25+15+25+) 50 / 250 points for correct implementation that uses only next2 and previous2, and not next and previous
+  test("[Skip2] fetch parallel result and check skip2 - simple combiners (10pts)") {
+    assertTaskCountSimple()
+    assertSkipSimple()
+    assertCorrectnessSimple()
+  }
+
+  test("[Skip2] fetch result and check skip2 - simple combiners (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessSimple()
+  }
+
+  test("[Skip2] fetch result and check skip2 - small combiners (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessBasic()
+  }
+
+  test("[Skip2] fetch result and check skip2 - small combiners after combining (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessCombined()
+  }
+
+  test("[Skip2] fetch result and check skip2 - large combiners (10pts)") {
+    assertSkipSimple()
+    assertCorrectnessLarge()
+  }
+
+  def assertSkipSimple(): Unit = simpleCombiners.foreach(elem => assertSkip(elem._1, elem._2))
+
+  def assertSkip(combiner: DLLCombinerTest, array: Array[Int]): Unit =
+    build(combiner, array)
+    combiner.result()
+    assertEquals(
+      combiner.nonSkipped,
+      false, {
+        s"ERROR: Calls to 'next' and 'previous' are not allowed! You should only use 'next2` and 'previous2' in your solution."
+      }
+    )
+
+  // (25+15+25+50+) 75 / 250 points for correct parallel implementation, exactly 1/4 of the array per task
+  test("[TaskFairness] each task should compute 1/4 of the result (15pts)") {
+    assertTaskFairness(simpleCombiners.unzip._1)
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - simple combiners (15pts)"
+  ) {
+    assertTaskFairness(simpleCombiners.unzip._1)
+    assertCorrectnessSimple()
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners (15pts)"
+  ) {
+    assertTaskFairness(basicCombiners.unzip._1)
+    assertCorrectnessBasic()
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners after combining (15pts)"
+  ) {
+    assertTaskFairness(combinedCombiners.unzip._1)
+    assertCorrectnessCombined()
+  }
+
+  test(
+    "[TaskFairness] each task should correctly compute 1/4 of the result - large combiners (15pts)"
+  ) {
+    assertTaskFairness(largeCombiners.unzip._1)
+    assertCorrectnessLarge()
+  }
+
+  def assertTaskFairness(combiners: List[DLLCombiner]): Unit =
+    def assertNewTaskFairness(combiner: DLLCombiner, task: ForkJoinTask[Unit], data: Array[Int]) =
+      var count = 0
+      var expected = combiner.size / 4
+      task.join
+      count = data.count(elem => elem != 0)
+      assert((count - expected).abs <= 1)
+
+    def assertMainTaskFairness(combiner: DLLCombiner, task: Unit, data: Array[Int]) =
+      var count = 0
+      var expected = combiner.size / 4
+      count = data.count(elem => elem != 0)
+      assert((count - expected).abs <= 1)
+
+    combiners.foreach { elem =>
+      var data = Array.fill(elem.size)(0)
+      assertNewTaskFairness(elem, elem.task1(data), data)
+
+      data = Array.fill(elem.size)(0)
+      assertNewTaskFairness(elem, elem.task2(data), data)
+
+      data = Array.fill(elem.size)(0)
+      assertNewTaskFairness(elem, elem.task3(data), data)
+
+      data = Array.fill(elem.size)(0)
+      assertMainTaskFairness(elem, elem.task4(data), data)
+    }
+
+  // (25+15+25+50+75+) 60 / 250 points for correct parallel implementation, exactly 1/4 of the array per task, exactly the specified quarter
+
+  test(
+    "[TaskPrecision] each task should compute specified 1/4 of the result - simple combiners (10pts)"
+  ) {
+    assertTaskPrecision(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task1 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision1(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task2 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision2(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task3 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision3(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] task4 should compute specified 1/4 of the result - simple combiners (5pts)"
+  ) {
+    assertTaskPrecision4(simpleCombiners)
+  }
+
+  test(
+    "[TaskPrecision] each task should compute specified 1/4 of the result - other combiners (30pts)"
+  ) {
+    assertTaskPrecision(basicCombiners)
+    assertTaskPrecision(combinedCombiners)
+    assertTaskPrecision(largeCombiners)
+  }
+
+  def assertTaskPrecision(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    assertTaskPrecision1(combiners)
+    assertTaskPrecision2(combiners)
+    assertTaskPrecision3(combiners)
+    assertTaskPrecision4(combiners)
+
+  def assertTaskPrecision1(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem =>
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task1 = elem._1.task1(data)
+      task1.join
+      Range(0, elem._1.size).foreach(i =>
+        (if i < elem._1.size / 2 - 1 && i % 2 == 0 then ref(i) = elem._2(i))
+      )
+      assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i)))
+    }
+
+  def assertTaskPrecision2(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem =>  
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task2 = elem._1.task2(data)
+      task2.join
+      Range(0, elem._1.size).foreach(i =>
+        (if i < elem._1.size / 2 - 1 && i % 2 == 1 then ref(i) = elem._2(i))
+      )
+      assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i)))
+  }
+
+  def assertTaskPrecision3(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem => 
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task3 = elem._1.task3(data)
+      task3.join
+      Range(0, elem._1.size).foreach(i =>
+        (if i > elem._1.size / 2 + 1 && i % 2 == elem._1.size % 2 then ref(i) = elem._2(i))
+      )
+      assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i)))
+    }
+
+  def assertTaskPrecision4(combiners: List[(DLLCombiner, Array[Int])]): Unit =
+    combiners.foreach { elem =>  
+      var data = Array.fill(elem._1.size)(0)
+      var ref = Array.fill(elem._1.size)(0)
+      val task4 = elem._1.task4(data)
+      Range(0, elem._1.size).foreach(i =>
+        (if i > elem._1.size / 2 + 1 && i % 2 != elem._1.size % 2 then ref(i) = elem._2(i))
+      )
+      assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i)))
+    }
+
+trait AbstractProblem1Suite extends munit.FunSuite with LibImpl:
+
+  def simpleCombiners = buildSimpleCombiners()
+  def basicCombiners = buildBasicCombiners()
+  def combinedCombiners = buildCombinedCombiners()
+  def largeCombiners = buildLargeCombiners()
+
+  def buildSimpleCombiners() =
+    val simpleCombiners = List(
+      (new DLLCombinerTest, Array(4, 2, 6, 1, 5, 4, 3, 5, 6, 3, 4, 5, 6, 3, 4, 5)),
+      (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 1, 1, 1, 1, 1, 1, 1, 2, 3, 2)),
+      (new DLLCombinerTest, Array.fill(16)(5))
+    )
+    simpleCombiners.foreach(elem => build(elem._1, elem._2))
+    simpleCombiners
+
+  def buildBasicCombiners() =
+    val basicCombiners = List(
+      (new DLLCombinerTest, Array(4, 2, 6)),
+      (new DLLCombinerTest, Array(4, 1, 6)),
+      (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 11, 12, 5, 14, 15, 1, 17, 23)),
+      (new DLLCombinerTest, Array(7, 2, 9, 9, 3, 2, 11, 12, 13, 14, 15, 16, 17, 22)),
+      (new DLLCombinerTest, Array.fill(16)(7)),
+      (new DLLCombinerTest, Array.fill(16)(4)),
+      (new DLLCombinerTest, Array.fill(5)(3)),
+      (new DLLCombinerTest, Array.fill(5)(7)),
+      (new DLLCombinerTest, Array.fill(5)(4))
+    )
+    basicCombiners.foreach(elem => build(elem._1, elem._2))
+    basicCombiners
+
+  def buildCombinedCombiners() =
+    var combinedCombiners = List[(DLLCombiner, Array[Int])]()
+
+    Range(1, 10).foreach { n =>
+      val array = basicCombiners.filter(elem => elem._1.size == n).foldLeft(Array[Int]()) {
+        (acc, i) => acc ++ i._2
+      }
+      val empty: DLLCombiner = new DLLCombinerTest
+      val combiner = basicCombiners.filter(elem => elem._1.size == n).map(_._1).foldLeft(empty) {
+        (acc, c) => acc.combine(c)
+      }
+
+      combinedCombiners = combinedCombiners :+ (combiner, array)
+    }
+    combinedCombiners
+
+  def buildLargeCombiners() =
+    val largeCombiners = List(
+      (new DLLCombinerTest, Array.fill(1321)(4) ++ Array.fill(1322)(7)),
+      (new DLLCombinerTest, Array.fill(1341)(2) ++ Array.fill(1122)(5)),
+      (
+        new DLLCombinerTest,
+        Array.fill(1321)(4) ++ Array.fill(1322)(7) ++ Array.fill(321)(4) ++ Array.fill(322)(7)
+      ),
+      (new DLLCombinerTest, Array.fill(992321)(4) ++ Array.fill(99322)(7)),
+      (new DLLCombinerTest, Array.fill(953211)(4) ++ Array.fill(999322)(1))
+    )
+    largeCombiners.foreach(elem => build(elem._1, elem._2))
+    largeCombiners
+
+  def build(combiner: DLLCombinerTest, array: Array[Int]): DLLCombinerTest =
+    array.foreach(elem => combiner += elem)
+    combiner
+
+  def compare(combiner: DLLCombiner, array: Array[Int]): Boolean =
+    val result = combiner.result()
+    Range(0, array.size).forall(i => array(i) == result(i))
+
+  def buildAndCompare(combiner: DLLCombinerTest, array: Array[Int]): Boolean =
+    array.foreach(elem => combiner += elem)
+    val result = combiner.result()
+    Range(0, array.size).forall(i => array(i) == result(i))
+
+trait LibImpl extends Problem1:
+
+  val forkJoinPool = new ForkJoinPool
+
+  abstract class TaskScheduler:
+    def schedule[T](body: => T): ForkJoinTask[T]
+
+  class DefaultTaskScheduler extends TaskScheduler:
+    def schedule[T](body: => T): ForkJoinTask[T] =
+      val t = new RecursiveTask[T]:
+        def compute = body
+      Thread.currentThread match
+        case wt: ForkJoinWorkerThread =>
+          t.fork()
+        case _ =>
+          forkJoinPool.execute(t)
+      t
+
+  val scheduler = new DynamicVariable[TaskScheduler](new DefaultTaskScheduler)
+
+  def task[T](body: => T): ForkJoinTask[T] = scheduler.value.schedule(body)
+
+  class NodeTest(val v: Int, val myCombiner: DLLCombinerTest) extends Node(v):
+    override def getNext: Node =
+      myCombiner.nonSkipped = true
+      next
+    override def getNext2: Node = next2
+    override def getPrevious: Node =
+      myCombiner.nonSkipped = true
+      previous
+    override def getPrevious2: Node = previous2
+    override def setNext(n: Node): Unit = next = n
+    override def setNext2(n: Node): Unit = next2 = n
+    override def setPrevious(n: Node): Unit = previous = n
+    override def setPrevious2(n: Node): Unit = previous2 = n
+
+  class DLLCombinerTest extends DLLCombinerImplementation:
+    var nonSkipped = false
+    override def result(): Array[Int] =
+      nonSkipped = false
+      super.result()
+    override def +=(elem: Int): Unit =
+      val node = new NodeTest(elem, this)
+      if size == 0 then
+        first = node
+        last = node
+        size = 1
+      else
+        last.setNext(node)
+        node.setPrevious(last)
+        node.setPrevious2(last.getPrevious)
+        if size > 1 then last.getPrevious.setNext2(node)
+        else second = node
+        secondToLast = last
+        last = node
+        size += 1
+    override def combine(that: DLLCombiner): DLLCombiner =
+      if this.size == 0 then that
+      else if that.size == 0 then this
+      else
+        this.last.setNext(that.first)
+        this.last.setNext2(that.first.getNext)
+        if this.last.getPrevious != null then
+          this.last.getPrevious.setNext2(that.first) // important
+
+        that.first.setPrevious(this.last)
+        that.first.setPrevious2(this.last.getPrevious)
+        if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important
+
+        if this.size == 1 then second = that.first
+
+        this.size = this.size + that.size
+        this.last = that.last
+        this.secondToLast = that.secondToLast
+
+        this
diff --git a/previous-exams/2022-final/concpar22final02.md b/previous-exams/2022-final/concpar22final02.md
new file mode 100644
index 0000000000000000000000000000000000000000..59dc15cb56c474ee823914cf0d38024460f14ece
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02.md
@@ -0,0 +1,38 @@
+# Problem 2: Wait and Notify
+
+## Setup
+
+Use the following commands to make a fresh clone of your repository:
+
+```
+git clone -b concpar22final02 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final02
+```
+
+If you have issues with the IDE, try [reimporting the
+build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting),
+if you still have problems, use `compile` in sbt instead.
+
+## Problem 2.1: Implement Barrier methods
+
+Your first task is to implement a _barrier_. A barrier acts as a synchronization point between multiple threads. It is used when you want all threads to finish a task before starting the next one. 
+
+You have to complete the following two methods in the `Barrier` class:
+1. `awaitZero` function waits for the count value to be zero.
+2. `countDown` function decrements the count by one. It notifies other threads if count is less than or equal to zero. 
+
+The barrier will be implemented using these functions. When the thread finish a task, it will decrement the count by using the `countDown` function. Then, it will call the `awaitZero` function to wait for other threads.
+
+## Problem 2.2: Use the Barrier to apply filters to an image
+
+In this part, each thread will apply an array of filters to each row of the image. Your task is to use the `Barrier` to act as a synchronization point while applying the filters. Each thread should wait for the other threads to complete the current filter before applying the next filter.
+
+`ImageLib.scala` provides an implementation of an image processing library with different filters. Each filter has a kernel which is applied on the image. `ImageLib.scala` implements four different filters. It provides an `applyFilter` method which applies a particular filter's kernel on a particular row on the input `Array` and generates the output in the output `Array`.
+
+The `ImageLib` class takes the size of an image as input. The image is a square matrix. The class has two buffers `buffer1` and `buffer2`. The initial image will be in `buffer1`. For the filtering task, you will switch between these buffers as input and output. For example, for the first filter `buffer1` will the input buffer and `buffer2` will be the output buffer. For the second filter `buffer2` will the input and `buffer1` will be the output and so on for subsequent filters.
+
+In `Problem2.scala` file where you will complete the `imagePipeline` function:
+
+- The `imagePipeline` function gets a array of filters and an array of row numbers. This filter needs to be applied to all the rows present in `row` array. After applying each filter, the thread has to wait for other threads to complete before applying the next filter. You will use barrier in this case. 
+- The `imagePipeline` function will return the output buffer. Note the output buffer can change between `buffer1` and `buffer2` depending on the number of filters applied.
+
+You can get partial points for solving parts of this exercise.
diff --git a/previous-exams/2022-final/concpar22final02/.gitignore b/previous-exams/2022-final/concpar22final02/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final/concpar22final02/assignment.sbt b/previous-exams/2022-final/concpar22final02/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final/concpar22final02/build.sbt b/previous-exams/2022-final/concpar22final02/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..61e5a6e3108affe1ca7212be2fdddb0f0a38d878
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/build.sbt
@@ -0,0 +1,11 @@
+course := "concpar"
+assignment := "concpar22final02"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "0.7.26" % Test
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final/concpar22final02/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final02/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final/concpar22final02/project/build.properties b/previous-exams/2022-final/concpar22final02/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final/concpar22final02/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final02/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final/concpar22final02/project/plugins.sbt b/previous-exams/2022-final/concpar22final02/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala
new file mode 100644
index 0000000000000000000000000000000000000000..decdbc9b912db765987efeb8cb78b554156829d5
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala
@@ -0,0 +1,11 @@
+package concpar22final02
+
+import instrumentation.Monitor
+
+abstract class AbstractBarrier(val numThreads: Int) extends Monitor:
+
+  var count = numThreads
+
+  def awaitZero(): Unit
+
+  def countDown(): Unit
diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala
new file mode 100644
index 0000000000000000000000000000000000000000..cec876244cc49ee7c08c729bddd3ee5bbb56a740
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala
@@ -0,0 +1,7 @@
+package concpar22final02
+
+class Barrier(numThreads: Int) extends AbstractBarrier(numThreads):
+
+  def awaitZero():Unit = ???
+
+  def countDown():Unit = ???
diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0f538ea513108b54c4a74bed76cd1828790f6e95
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala
@@ -0,0 +1,47 @@
+package concpar22final02
+
+import scala.collection.mutable.ArrayBuffer
+
+class ImageLib(size: Int):
+
+  val buffer1: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(1)
+  val buffer2: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(0)
+
+  enum Filter(val kernel: Array[Array[Int]]):
+    case Outline extends Filter(Array(Array(-1, -1, -1), Array(-1, 8, -1), Array(-1, -1, -1)))
+    case Sharpen extends Filter(Array(Array(0, -1, 0), Array(-1, 5, -1), Array(0, -1, 0)))
+    case Emboss extends Filter(Array(Array(-2, -1, 0), Array(-1, 1, 1), Array(0, 1, 2)))
+    case Identity extends Filter(Array(Array(0, 0, 0), Array(0, 1, 0), Array(0, 0, 0)))
+
+  def init(input: ArrayBuffer[ArrayBuffer[Int]]) =
+    for i <- 0 to size - 1 do
+      for j <- 0 to size - 1 do
+        buffer1(i)(j) = input(i)(j)
+
+  def computeConvolution(
+      kernel: Array[Array[Int]],
+      input: ArrayBuffer[ArrayBuffer[Int]],
+      row: Int,
+      column: Int
+  ): Int =
+
+    val displacement = Array(-1, 0, 1)
+    var output = 0
+
+    for i <- 0 to 2 do
+      for j <- 0 to 2 do
+        val newI = row + displacement(i)
+        val newJ = column + displacement(j)
+        if newI < 0 || newI >= size || newJ < 0 || newJ >= size then output += 0
+        else output += (kernel(i)(j) * input(newI)(newJ))
+
+    output
+
+  def applyFilter(
+      kernel: Array[Array[Int]],
+      input: ArrayBuffer[ArrayBuffer[Int]],
+      output: ArrayBuffer[ArrayBuffer[Int]],
+      row: Int
+  ): Unit =
+    for i <- 0 to input(row).size - 1 do
+      output(row)(i) = computeConvolution(kernel, input, row, i)
diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala
new file mode 100644
index 0000000000000000000000000000000000000000..b09951b5f42d039364e16a7d9efc6ef966df831b
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala
@@ -0,0 +1,12 @@
+package concpar22final02
+
+import java.util.concurrent.atomic.AtomicInteger
+import scala.collection.mutable.ArrayBuffer
+
+class Problem2(imageSize: Int, numThreads: Int, numFilters: Int):
+
+  val barrier: ArrayBuffer[Barrier] = ArrayBuffer.fill(numFilters)(Barrier(numThreads))
+
+  val imageLib: ImageLib = ImageLib(imageSize)
+
+  def imagePipeline(filters: Array[imageLib.Filter], row: Array[Int]): ArrayBuffer[ArrayBuffer[Int]] = ???
diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala
new file mode 100644
index 0000000000000000000000000000000000000000..27183378d9af236e05a04baca4b9311d87cc13a2
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala
@@ -0,0 +1,32 @@
+package concpar22final02.instrumentation
+
+class Dummy
+
+trait Monitor:
+  implicit val dummy: Dummy = new Dummy
+
+  def wait()(implicit i: Dummy) = waitDefault()
+
+  def synchronized[T](e: => T)(implicit i: Dummy) = synchronizedDefault(e)
+
+  def notify()(implicit i: Dummy) = notifyDefault()
+
+  def notifyAll()(implicit i: Dummy) = notifyAllDefault()
+
+  private val lock = new AnyRef
+
+  // Can be overriden.
+  def waitDefault(): Unit = lock.wait()
+  def synchronizedDefault[T](toExecute: => T): T = lock.synchronized(toExecute)
+  def notifyDefault(): Unit = lock.notify()
+  def notifyAllDefault(): Unit = lock.notifyAll()
+
+trait LockFreeMonitor extends Monitor:
+  override def waitDefault() =
+    throw new Exception("Please use lock-free structures and do not use wait()")
+  override def synchronizedDefault[T](toExecute: => T): T =
+    throw new Exception("Please use lock-free structures and do not use synchronized()")
+  override def notifyDefault() =
+    throw new Exception("Please use lock-free structures and do not use notify()")
+  override def notifyAllDefault() =
+    throw new Exception("Please use lock-free structures and do not use notifyAll()")
diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala
new file mode 100644
index 0000000000000000000000000000000000000000..fb4a31e20d3d02f8f81f24a012f3fb488f60168a
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala
@@ -0,0 +1,19 @@
+/* Copyright 2009-2015 EPFL, Lausanne */
+package concpar22final02.instrumentation
+
+import java.lang.management.*
+
+/** A collection of methods that can be used to collect run-time statistics about Leon programs.
+  * This is mostly used to test the resources properties of Leon programs
+  */
+object Stats:
+  def timed[T](code: => T)(cont: Long => Unit): T =
+    var t1 = System.currentTimeMillis()
+    val r = code
+    cont((System.currentTimeMillis() - t1))
+    r
+
+  def withTime[T](code: => T): (T, Long) =
+    var t1 = System.currentTimeMillis()
+    val r = code
+    (r, (System.currentTimeMillis() - t1))
diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..95da2fc36f3337be15a05a65ed416dc43e50e622
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala
@@ -0,0 +1,413 @@
+package concpar22final02
+
+import scala.concurrent.*
+import scala.concurrent.duration.*
+import scala.collection.mutable.HashMap
+import scala.util.Random
+import instrumentation.SchedulableProblem2
+
+import instrumentation.TestHelper.*
+import instrumentation.TestUtils.*
+import scala.collection.mutable.ArrayBuffer
+
+class Problem2Suite extends munit.FunSuite:
+
+  val imageSize = 5
+  val nThreads = 3
+
+  def rowsForThread(threadNumber: Int): Array[Int] =
+    val start: Int = (imageSize * threadNumber) / nThreads
+    val end: Int = (imageSize * (threadNumber + 1)) / nThreads
+    (start until end).toArray
+
+  test("Should work when barrier is called by a single thread (10pts)") {
+    testManySchedules(
+      1,
+      sched =>
+        val temp = new Problem2(imageSize, 1, 1)
+        (
+          List(() => temp.barrier(0).countDown()),
+          results =>
+            if sched.notifyCount == 0 && sched.notifyAllCount == 0 then
+              val notifyCount = sched.notifyCount
+              val notifyAllCount = sched.notifyAllCount
+              (false, s"No notify call $notifyCount $notifyAllCount")
+            else if temp.barrier(0).count != 0 then
+              val count = temp.barrier(0).count
+              (false, s"Barrier count not equal to zero: $count")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when a single thread processes a single filter (10pts)") {
+    val temp = new Problem2(imageSize, 1, 1)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(Array(temp.imageLib.Filter.Outline), Array(0, 1, 2, 3, 4))
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(-2, -3, -3, -3, -2),
+        ArrayBuffer(3, 0, 0, 0, 3),
+        ArrayBuffer(6, 0, 0, 0, 6),
+        ArrayBuffer(9, 0, 0, 0, 9),
+        ArrayBuffer(22, 15, 15, 15, 22)
+      )
+    )
+  }
+
+  test("Should work when a single thread processes a 2 same filters (15pts)") {
+    val temp = new Problem2(imageSize, 1, 2)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Identity),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+  }
+
+  test("Should work when a single thread processes a 2 different filters (15pts)") {
+    val temp = new Problem2(imageSize, 1, 2)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Outline),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(-2, -3, -3, -3, -2),
+        ArrayBuffer(3, 0, 0, 0, 3),
+        ArrayBuffer(6, 0, 0, 0, 6),
+        ArrayBuffer(9, 0, 0, 0, 9),
+        ArrayBuffer(22, 15, 15, 15, 22)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(0, 0, 0, 0, 0),
+        ArrayBuffer(1, 1, 1, 1, 1),
+        ArrayBuffer(2, 2, 2, 2, 2),
+        ArrayBuffer(3, 3, 3, 3, 3),
+        ArrayBuffer(4, 4, 4, 4, 4)
+      )
+    )
+  }
+
+  test("Should work when barrier is called by two threads (25pts)") {
+    testManySchedules(
+      2,
+      sched =>
+        val temp = new Problem2(imageSize, 2, 1)
+        (
+          List(
+            () =>
+              temp.barrier(0).countDown()
+              temp.barrier(0).awaitZero()
+            ,
+            () =>
+              temp.barrier(0).countDown()
+              temp.barrier(0).awaitZero()
+          ),
+          results =>
+            if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call")
+            else if sched.waitCount == 0 then (false, s"No wait call")
+            else if temp.barrier(0).count != 0 then
+              val count = temp.barrier(0).count
+              (false, s"Barrier count not equal to zero: $count")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when barrier is called by multiple threads (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new Problem2(imageSize, nThreads, 1)
+        (
+          (for i <- 0 until nThreads yield () =>
+            temp.barrier(0).countDown()
+            temp.barrier(0).awaitZero()
+          ).toList,
+          results =>
+            if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call")
+            else if sched.waitCount == 0 then (false, s"No wait call")
+            else if temp.barrier(0).count != 0 then
+              val count = temp.barrier(0).count
+              (false, s"Barrier count not equal to zero: $count")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when a single thread processes a multiple same filters (25pts)") {
+    val temp = new Problem2(imageSize, 1, 3)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(
+        temp.imageLib.Filter.Outline,
+        temp.imageLib.Filter.Outline,
+        temp.imageLib.Filter.Outline
+      ),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(-128, -173, -107, -173, -128),
+        ArrayBuffer(205, -2, 172, -2, 205),
+        ArrayBuffer(322, -128, 208, -128, 322),
+        ArrayBuffer(55, -854, -428, -854, 55),
+        ArrayBuffer(1180, 433, 751, 433, 1180)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(-16, -22, -18, -22, -16),
+        ArrayBuffer(23, -1, 9, -1, 23),
+        ArrayBuffer(36, -18, 0, -18, 36),
+        ArrayBuffer(29, -67, -45, -67, 29),
+        ArrayBuffer(152, 74, 90, 74, 152)
+      )
+    )
+  }
+
+  test("Should work when a single thread processes multiple filters (25pts)") {
+    val temp = new Problem2(imageSize, 1, 3)
+    val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+    for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+    temp.imageLib.init(buf)
+    temp.imagePipeline(
+      Array(
+        temp.imageLib.Filter.Identity,
+        temp.imageLib.Filter.Outline,
+        temp.imageLib.Filter.Sharpen
+      ),
+      Array(0, 1, 2, 3, 4)
+    )
+    assertEquals(
+      temp.imageLib.buffer1,
+      ArrayBuffer(
+        ArrayBuffer(-2, -3, -3, -3, -2),
+        ArrayBuffer(3, 0, 0, 0, 3),
+        ArrayBuffer(6, 0, 0, 0, 6),
+        ArrayBuffer(9, 0, 0, 0, 9),
+        ArrayBuffer(22, 15, 15, 15, 22)
+      )
+    )
+    assertEquals(
+      temp.imageLib.buffer2,
+      ArrayBuffer(
+        ArrayBuffer(-10, -10, -9, -10, -10),
+        ArrayBuffer(11, 0, 3, 0, 11),
+        ArrayBuffer(18, -6, 0, -6, 18),
+        ArrayBuffer(17, -24, -15, -24, 17),
+        ArrayBuffer(86, 38, 45, 38, 86)
+      )
+    )
+  }
+
+  test("Should work when multiple thread processes a single filter (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 1)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(Array(temp.imageLib.Filter.Outline), rowsForThread(i))).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(1, 1, 1, 1, 1)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(5, 3, 3, 3, 5),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(5, 3, 3, 3, 5)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when multiple thread processes two filters (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 2)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(
+              Array(temp.imageLib.Filter.Outline, temp.imageLib.Filter.Sharpen),
+              rowsForThread(i)
+            )).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(19, 7, 9, 7, 19),
+              ArrayBuffer(7, -6, -3, -6, 7),
+              ArrayBuffer(9, -3, 0, -3, 9),
+              ArrayBuffer(7, -6, -3, -6, 7),
+              ArrayBuffer(19, 7, 9, 7, 19)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(5, 3, 3, 3, 5),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(3, 0, 0, 0, 3),
+              ArrayBuffer(5, 3, 3, 3, 5)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when multiple thread processes multiple same filters (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4)
+        val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+        for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+        temp.imageLib.init(buf)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(
+              Array(
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Identity
+              ),
+              rowsForThread(i)
+            )).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(0, 0, 0, 0, 0),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(2, 2, 2, 2, 2),
+              ArrayBuffer(3, 3, 3, 3, 3),
+              ArrayBuffer(4, 4, 4, 4, 4)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(0, 0, 0, 0, 0),
+              ArrayBuffer(1, 1, 1, 1, 1),
+              ArrayBuffer(2, 2, 2, 2, 2),
+              ArrayBuffer(3, 3, 3, 3, 3),
+              ArrayBuffer(4, 4, 4, 4, 4)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
+
+  test("Should work when multiple thread processes multiple different filters (25pts)") {
+    testManySchedules(
+      nThreads,
+      sched =>
+        val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4)
+        val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer()
+        for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i)
+        temp.imageLib.init(buf)
+        (
+          (for i <- 0 until nThreads
+          yield () =>
+            temp.imagePipeline(
+              Array(
+                temp.imageLib.Filter.Outline,
+                temp.imageLib.Filter.Sharpen,
+                temp.imageLib.Filter.Identity,
+                temp.imageLib.Filter.Sharpen
+              ),
+              rowsForThread(i)
+            )).toList,
+          results =>
+            val expected_buffer1 = ArrayBuffer(
+              ArrayBuffer(-51, -31, -28, -31, -51),
+              ArrayBuffer(47, 2, 24, 2, 47),
+              ArrayBuffer(68, -24, 24, -24, 68),
+              ArrayBuffer(5, -154, -72, -154, 5),
+              ArrayBuffer(375, 83, 164, 83, 375)
+            )
+            val expected_buffer2 = ArrayBuffer(
+              ArrayBuffer(-10, -10, -9, -10, -10),
+              ArrayBuffer(11, 0, 3, 0, 11),
+              ArrayBuffer(18, -6, 0, -6, 18),
+              ArrayBuffer(17, -24, -15, -24, 17),
+              ArrayBuffer(86, 38, 45, 38, 86)
+            )
+            val res_buffer1 = temp.imageLib.buffer1
+            val res_buffer2 = temp.imageLib.buffer2
+            if res_buffer1 != expected_buffer1 then
+              (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1")
+            else if res_buffer2 != expected_buffer2 then
+              (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2")
+            else (true, "")
+        )
+    )
+  }
diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala
new file mode 100644
index 0000000000000000000000000000000000000000..645f9cb4a0398faf65a1bbf798a2d91207f7b38a
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala
@@ -0,0 +1,57 @@
+package concpar22final02.instrumentation
+
+trait MockedMonitor extends Monitor:
+  def scheduler: Scheduler
+
+  // Can be overriden.
+  override def waitDefault() =
+    scheduler.log("wait")
+    scheduler.waitCount.incrementAndGet()
+    scheduler updateThreadState Wait(this, scheduler.threadLocks.tail)
+  override def synchronizedDefault[T](toExecute: => T): T =
+    scheduler.log("synchronized check")
+    val prevLocks = scheduler.threadLocks
+    scheduler updateThreadState Sync(
+      this,
+      prevLocks
+    ) // If this belongs to prevLocks, should just continue.
+    scheduler.log("synchronized -> enter")
+    try toExecute
+    finally
+      scheduler updateThreadState Running(prevLocks)
+      scheduler.log("synchronized -> out")
+  override def notifyDefault() =
+    scheduler mapOtherStates { state =>
+      state match
+        case Wait(lockToAquire, locks) if lockToAquire == this => SyncUnique(this, state.locks)
+        case e                                                 => e
+    }
+    scheduler.notifyCount.incrementAndGet()
+    scheduler.log("notify")
+  override def notifyAllDefault() =
+    scheduler mapOtherStates { state =>
+      state match
+        case Wait(lockToAquire, locks) if lockToAquire == this       => Sync(this, state.locks)
+        case SyncUnique(lockToAquire, locks) if lockToAquire == this => Sync(this, state.locks)
+        case e                                                       => e
+    }
+    scheduler.notifyAllCount.incrementAndGet()
+    scheduler.log("notifyAll")
+
+abstract class ThreadState:
+  def locks: Seq[AnyRef]
+trait CanContinueIfAcquiresLock extends ThreadState:
+  def lockToAquire: AnyRef
+case object Start extends ThreadState:
+  def locks: Seq[AnyRef] = Seq.empty
+case object End extends ThreadState:
+  def locks: Seq[AnyRef] = Seq.empty
+case class Wait(lockToAquire: AnyRef, locks: Seq[AnyRef]) extends ThreadState
+case class SyncUnique(lockToAquire: AnyRef, locks: Seq[AnyRef])
+    extends ThreadState
+    with CanContinueIfAcquiresLock
+case class Sync(lockToAquire: AnyRef, locks: Seq[AnyRef])
+    extends ThreadState
+    with CanContinueIfAcquiresLock
+case class Running(locks: Seq[AnyRef]) extends ThreadState
+case class VariableReadWrite(locks: Seq[AnyRef]) extends ThreadState
diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a14587b96699a005681ed720427fb29fae554b64
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala
@@ -0,0 +1,20 @@
+package concpar22final02.instrumentation
+
+import scala.annotation.tailrec
+import concpar22final02.*
+import scala.collection.mutable.ArrayBuffer
+
+class SchedulableBarrier(val scheduler: Scheduler, size: Int)
+    extends Barrier(size)
+    with MockedMonitor
+
+class SchedulableProblem2(
+    val scheduler: Scheduler,
+    imageSize: Int,
+    threadCount: Int,
+    numFilters: Int
+) extends Problem2(imageSize, threadCount, numFilters):
+  self =>
+
+  override val barrier =
+    ArrayBuffer.fill(numFilters)(SchedulableBarrier(scheduler, threadCount))
diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala
new file mode 100644
index 0000000000000000000000000000000000000000..4001ee336eb0d42d7b687eac3d972709c8976c18
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala
@@ -0,0 +1,294 @@
+package concpar22final02.instrumentation
+
+import java.util.concurrent.*
+import scala.concurrent.duration.*
+import scala.collection.mutable.*
+import Stats.*
+
+import java.util.concurrent.atomic.AtomicInteger
+
+sealed abstract class Result
+case class RetVal(rets: List[Any]) extends Result
+case class Except(msg: String, stackTrace: Array[StackTraceElement]) extends Result
+case class Timeout(msg: String) extends Result
+
+/** A class that maintains schedule and a set of thread ids. The schedules are advanced after an
+  * operation of a SchedulableBuffer is performed. Note: the real schedule that is executed may
+  * deviate from the input schedule due to the adjustments that had to be made for locks
+  */
+class Scheduler(sched: List[Int]):
+  val maxOps = 500 // a limit on the maximum number of operations the code is allowed to perform
+
+  var waitCount:AtomicInteger = new AtomicInteger(0)
+  var notifyCount:AtomicInteger = new AtomicInteger(0)
+  var notifyAllCount:AtomicInteger = new AtomicInteger(0)
+  
+  private var schedule = sched
+  var numThreads = 0
+  private val realToFakeThreadId = Map[Long, Int]()
+  private val opLog = ListBuffer[String]() // a mutable list (used for efficient concat)
+  private val threadStates = Map[Int, ThreadState]()
+
+  /** Runs a set of operations in parallel as per the schedule. Each operation may consist of many
+    * primitive operations like reads or writes to shared data structure each of which should be
+    * executed using the function `exec`.
+    * @timeout
+    *   in milliseconds
+    * @return
+    *   true - all threads completed on time, false -some tests timed out.
+    */
+  def runInParallel(timeout: Long, ops: List[() => Any]): Result =
+    numThreads = ops.length
+    val threadRes = Array.fill(numThreads) { None: Any }
+    var exception: Option[(Throwable, Int)] = None
+    val syncObject = new Object()
+    var completed = new AtomicInteger(0)
+    // create threads
+    val threads = ops.zipWithIndex.map { case (op, i) =>
+      new Thread(
+        new Runnable():
+          def run(): Unit =
+            val fakeId = i + 1
+            setThreadId(fakeId)
+            try
+              updateThreadState(Start)
+              val res = op()
+              updateThreadState(End)
+              threadRes(i) = res
+              // notify the main thread if all threads have completed
+              if completed.incrementAndGet() == ops.length then
+                syncObject.synchronized { syncObject.notifyAll() }
+            catch
+              case e: Throwable if exception != None => // do nothing here and silently fail
+              case e: Throwable =>
+                log(s"throw ${e.toString}")
+                exception = Some((e, fakeId))
+                syncObject.synchronized { syncObject.notifyAll() }
+            // println(s"$fakeId: ${e.toString}")
+            // Runtime.getRuntime().halt(0) //exit the JVM and all running threads (no other way to kill other threads)
+      )
+    }
+    // start all threads
+    threads.foreach(_.start())
+    // wait for all threads to complete, or for an exception to be thrown, or for the time out to expire
+    var remTime = timeout
+    syncObject.synchronized {
+      timed {
+        if completed.get() != ops.length then syncObject.wait(timeout) } { time =>
+        remTime -= time
+      }
+    }
+    if exception.isDefined then
+      Except(
+        s"Thread ${exception.get._2} crashed on the following schedule: \n" + opLog.mkString("\n"),
+        exception.get._1.getStackTrace
+      )
+    else if remTime <= 1 then // timeout ? using 1 instead of zero to allow for some errors
+      Timeout(opLog.mkString("\n"))
+    else
+      // every thing executed normally
+      RetVal(threadRes.toList)
+
+  // Updates the state of the current thread
+  def updateThreadState(state: ThreadState): Unit =
+    val tid = threadId
+    synchronized {
+      threadStates(tid) = state
+    }
+    state match
+      case Sync(lockToAquire, locks) =>
+        if locks.indexOf(lockToAquire) < 0 then waitForTurn
+        else
+          // Re-aqcuiring the same lock
+          updateThreadState(Running(lockToAquire +: locks))
+      case Start      => waitStart()
+      case End        => removeFromSchedule(tid)
+      case Running(_) =>
+      case _          => waitForTurn // Wait, SyncUnique, VariableReadWrite
+
+  def waitStart(): Unit =
+    // while (threadStates.size < numThreads) {
+    // Thread.sleep(1)
+    // }
+    synchronized {
+      if threadStates.size < numThreads then wait()
+      else notifyAll()
+    }
+
+  def threadLocks =
+    synchronized {
+      threadStates(threadId).locks
+    }
+
+  def threadState =
+    synchronized {
+      threadStates(threadId)
+    }
+
+  def mapOtherStates(f: ThreadState => ThreadState) =
+    val exception = threadId
+    synchronized {
+      for k <- threadStates.keys if k != exception do threadStates(k) = f(threadStates(k))
+    }
+
+  def log(str: String) =
+    if (realToFakeThreadId contains Thread.currentThread().getId()) then
+      val space = (" " * ((threadId - 1) * 2))
+      val s = space + threadId + ":" + "\n".r.replaceAllIn(str, "\n" + space + "  ")
+      opLog += s
+
+  /** Executes a read or write operation to a global data structure as per the given schedule
+    * @param msg
+    *   a message corresponding to the operation that will be logged
+    */
+  def exec[T](primop: => T)(msg: => String, postMsg: => Option[T => String] = None): T =
+    if !(realToFakeThreadId contains Thread.currentThread().getId()) then primop
+    else
+      updateThreadState(VariableReadWrite(threadLocks))
+      val m = msg
+      if m != "" then log(m)
+      if opLog.size > maxOps then
+        throw new Exception(
+          s"Total number of reads/writes performed by threads exceed $maxOps. A possible deadlock!"
+        )
+      val res = primop
+      postMsg match
+        case Some(m) => log(m(res))
+        case None    =>
+      res
+
+  private def setThreadId(fakeId: Int) = synchronized {
+    realToFakeThreadId(Thread.currentThread.getId) = fakeId
+  }
+
+  def threadId =
+    try realToFakeThreadId(Thread.currentThread().getId())
+    catch
+      case e: NoSuchElementException =>
+        throw new Exception(
+          "You are accessing shared variables in the constructor. This is not allowed. The variables are already initialized!"
+        )
+
+  private def isTurn(tid: Int) = synchronized {
+    (!schedule.isEmpty && schedule.head != tid)
+  }
+
+  def canProceed(): Boolean =
+    val tid = threadId
+    canContinue match
+      case Some((i, state)) if i == tid =>
+        // println(s"$tid: Runs ! Was in state $state")
+        canContinue = None
+        state match
+          case Sync(lockToAquire, locks) => updateThreadState(Running(lockToAquire +: locks))
+          case SyncUnique(lockToAquire, locks) =>
+            mapOtherStates {
+              _ match
+                case SyncUnique(lockToAquire2, locks2) if lockToAquire2 == lockToAquire =>
+                  Wait(lockToAquire2, locks2)
+                case e => e
+            }
+            updateThreadState(Running(lockToAquire +: locks))
+          case VariableReadWrite(locks) => updateThreadState(Running(locks))
+        true
+      case Some((i, state)) =>
+        // println(s"$tid: not my turn but $i !")
+        false
+      case None =>
+        false
+
+  var threadPreference =
+    0 // In the case the schedule is over, which thread should have the preference to execute.
+
+  /** returns true if the thread can continue to execute, and false otherwise */
+  def decide(): Option[(Int, ThreadState)] =
+    if !threadStates.isEmpty
+    then // The last thread who enters the decision loop takes the decision.
+      // println(s"$threadId: I'm taking a decision")
+      if threadStates.values.forall {
+          case e: Wait => true
+          case _       => false
+        }
+      then
+        val waiting = threadStates.keys.map(_.toString).mkString(", ")
+        val s = if threadStates.size > 1 then "s" else ""
+        val are = if threadStates.size > 1 then "are" else "is"
+        throw new Exception(
+          s"Deadlock: Thread$s $waiting $are waiting but all others have ended and cannot notify them."
+        )
+      else
+        // Threads can be in Wait, Sync, SyncUnique, and VariableReadWrite mode.
+        // Let's determine which ones can continue.
+        val notFree = threadStates.collect { case (id, state) => state.locks }.flatten.toSet
+        val threadsNotBlocked = threadStates.toSeq.filter {
+          case (id, v: VariableReadWrite) => true
+          case (id, v: CanContinueIfAcquiresLock) =>
+            !notFree(v.lockToAquire) || (v.locks contains v.lockToAquire)
+          case _ => false
+        }
+        if threadsNotBlocked.isEmpty then
+          val waiting = threadStates.keys.map(_.toString).mkString(", ")
+          val s = if threadStates.size > 1 then "s" else ""
+          val are = if threadStates.size > 1 then "are" else "is"
+          val whoHasLock = threadStates.toSeq.flatMap { case (id, state) =>
+            state.locks.map(lock => (lock, id))
+          }.toMap
+          val reason = threadStates
+            .collect {
+              case (id, state: CanContinueIfAcquiresLock) if !notFree(state.lockToAquire) =>
+                s"Thread $id is waiting on lock ${state.lockToAquire} held by thread ${whoHasLock(state.lockToAquire)}"
+            }
+            .mkString("\n")
+          throw new Exception(s"Deadlock: Thread$s $waiting are interlocked. Indeed:\n$reason")
+        else if threadsNotBlocked.size == 1
+        then // Do not consume the schedule if only one thread can execute.
+          Some(threadsNotBlocked(0))
+        else
+          val next =
+            schedule.indexWhere(t => threadsNotBlocked.exists { case (id, state) => id == t })
+          if next != -1 then
+            // println(s"$threadId: schedule is $schedule, next chosen is ${schedule(next)}")
+            val chosenOne = schedule(next) // TODO: Make schedule a mutable list.
+            schedule = schedule.take(next) ++ schedule.drop(next + 1)
+            Some((chosenOne, threadStates(chosenOne)))
+          else
+            threadPreference = (threadPreference + 1) % threadsNotBlocked.size
+            val chosenOne = threadsNotBlocked(threadPreference) // Maybe another strategy
+            Some(chosenOne)
+            // threadsNotBlocked.indexOf(threadId) >= 0
+            /*
+            val tnb = threadsNotBlocked.map(_._1).mkString(",")
+            val s = if (schedule.isEmpty) "empty" else schedule.mkString(",")
+            val only = if (schedule.isEmpty) "" else " only"
+            throw new Exception(s"The schedule is $s but$only threads ${tnb} can continue")*/
+    else canContinue
+
+  /** This will be called before a schedulable operation begins. This should not use synchronized
+    */
+  var numThreadsWaiting = new AtomicInteger(0)
+  // var waitingForDecision = Map[Int, Option[Int]]() // Mapping from thread ids to a number indicating who is going to make the choice.
+  var canContinue: Option[(Int, ThreadState)] =
+    None // The result of the decision thread Id of the thread authorized to continue.
+  private def waitForTurn =
+    synchronized {
+      if numThreadsWaiting.incrementAndGet() == threadStates.size then
+        canContinue = decide()
+        notifyAll()
+      // waitingForDecision(threadId) = Some(numThreadsWaiting)
+      // println(s"$threadId Entering waiting with ticket number $numThreadsWaiting/${waitingForDecision.size}")
+      while !canProceed() do wait()
+    }
+    numThreadsWaiting.decrementAndGet()
+
+  /** To be invoked when a thread is about to complete
+    */
+  private def removeFromSchedule(fakeid: Int) = synchronized {
+    // println(s"$fakeid: I'm taking a decision because I finished")
+    schedule = schedule.filterNot(_ == fakeid)
+    threadStates -= fakeid
+    if numThreadsWaiting.get() == threadStates.size then
+      canContinue = decide()
+      notifyAll()
+  }
+
+  def getOperationLog() = opLog
diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala
new file mode 100644
index 0000000000000000000000000000000000000000..c4bcda003027cee03742303fac8dbcfd209f48db
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala
@@ -0,0 +1,127 @@
+package concpar22final02.instrumentation
+
+import scala.util.Random
+import scala.collection.mutable.{Map as MutableMap}
+
+import Stats.*
+
+object TestHelper:
+  val noOfSchedules = 10000 // set this to 100k during deployment
+  val readWritesPerThread = 20 // maximum number of read/writes possible in one thread
+  val contextSwitchBound = 10
+  val testTimeout = 240 // the total time out for a test in seconds
+  val schedTimeout = 15 // the total time out for execution of a schedule in secs
+
+  // Helpers
+  /*def testManySchedules(op1: => Any): Unit = testManySchedules(List(() => op1))
+  def testManySchedules(op1: => Any, op2: => Any): Unit = testManySchedules(List(() => op1, () => op2))
+  def testManySchedules(op1: => Any, op2: => Any, op3: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3))
+  def testManySchedules(op1: => Any, op2: => Any, op3: => Any, op4: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3, () => op4))*/
+
+  def testSequential[T](ops: Scheduler => Any)(assertions: T => (Boolean, String)) =
+    testManySchedules(
+      1,
+      (sched: Scheduler) =>
+        (List(() => ops(sched)), (res: List[Any]) => assertions(res.head.asInstanceOf[T]))
+    )
+
+  /** @numThreads
+    *   number of threads
+    * @ops
+    *   operations to be executed, one per thread
+    * @assertion
+    *   as condition that will executed after all threads have completed (without exceptions) the
+    *   arguments are the results of the threads
+    */
+  def testManySchedules(
+      numThreads: Int,
+      ops: Scheduler => (
+          List[() => Any], // Threads
+          List[Any] => (Boolean, String)
+      ) // Assertion
+  ) =
+    var timeout = testTimeout * 1000L
+    val threadIds = (1 to numThreads)
+    // (1 to scheduleLength).flatMap(_ => threadIds).toList.permutations.take(noOfSchedules).foreach {
+    val schedules = (new ScheduleGenerator(numThreads)).schedules()
+    var schedsExplored = 0
+    schedules.takeWhile(_ => schedsExplored <= noOfSchedules && timeout > 0).foreach {
+      // case _ if timeout <= 0 => // break
+      case schedule =>
+        schedsExplored += 1
+        val schedr = new Scheduler(schedule)
+        // println("Exploring Sched: "+schedule)
+        val (threadOps, assertion) = ops(schedr)
+        if threadOps.size != numThreads then
+          throw new IllegalStateException(
+            s"Number of threads: $numThreads, do not match operations of threads: $threadOps"
+          )
+        timed { schedr.runInParallel(schedTimeout * 1000, threadOps) } { t => timeout -= t } match
+          case Timeout(msg) =>
+            throw new java.lang.AssertionError(
+              "assertion failed\n" + "The schedule took too long to complete. A possible deadlock! \n" + msg
+            )
+          case Except(msg, stkTrace) =>
+            val traceStr =
+              "Thread Stack trace: \n" + stkTrace.map(" at " + _.toString).mkString("\n")
+            throw new java.lang.AssertionError("assertion failed\n" + msg + "\n" + traceStr)
+          case RetVal(threadRes) =>
+            // check the assertion
+            val (success, custom_msg) = assertion(threadRes)
+            if !success then
+              val msg =
+                "The following schedule resulted in wrong results: \n" + custom_msg + "\n" + schedr
+                  .getOperationLog()
+                  .mkString("\n")
+              throw new java.lang.AssertionError("Assertion failed: " + msg)
+    }
+    if timeout <= 0 then
+      throw new java.lang.AssertionError(
+        "Test took too long to complete! Cannot check all schedules as your code is too slow!"
+      )
+
+  /** A schedule generator that is based on the context bound
+    */
+  class ScheduleGenerator(numThreads: Int):
+    val scheduleLength = readWritesPerThread * numThreads
+    val rands = (1 to scheduleLength).map(i => new Random(0xcafe * i)) // random numbers for choosing a thread at each position
+    def schedules(): LazyList[List[Int]] =
+      var contextSwitches = 0
+      var contexts = List[Int]() // a stack of thread ids in the order of context-switches
+      val remainingOps = MutableMap[Int, Int]()
+      remainingOps ++= (1 to numThreads).map(i =>
+        (i, readWritesPerThread)
+      ) // num ops remaining in each thread
+      val liveThreads = (1 to numThreads).toSeq.toBuffer
+
+      /** Updates remainingOps and liveThreads once a thread is chosen for a position in the
+        * schedule
+        */
+      def updateState(tid: Int): Unit =
+        val remOps = remainingOps(tid)
+        if remOps == 0 then liveThreads -= tid
+        else remainingOps += (tid -> (remOps - 1))
+      val schedule = rands.foldLeft(List[Int]()) {
+        case (acc, r) if contextSwitches < contextSwitchBound =>
+          val tid = liveThreads(r.nextInt(liveThreads.size))
+          contexts match
+            case prev :: tail if prev != tid => // we have a new context switch here
+              contexts +:= tid
+              contextSwitches += 1
+            case prev :: tail =>
+            case _ => // init case
+              contexts +:= tid
+          updateState(tid)
+          acc :+ tid
+        case (
+              acc,
+              _
+            ) => // here context-bound has been reached so complete the schedule without any more context switches
+          if !contexts.isEmpty then contexts = contexts.dropWhile(remainingOps(_) == 0)
+          val tid = contexts match
+            case top :: tail => top
+            case _ => liveThreads(0) // here, there has to be threads that have not even started
+          updateState(tid)
+          acc :+ tid
+      }
+      schedule #:: schedules()
diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala
new file mode 100644
index 0000000000000000000000000000000000000000..5c76ec9ba4020737013b5302b7e3af0e61cc8898
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala
@@ -0,0 +1,14 @@
+package concpar22final02.instrumentation
+
+import scala.concurrent.*
+import scala.concurrent.duration.*
+import scala.concurrent.ExecutionContext.Implicits.global
+
+object TestUtils:
+  def failsOrTimesOut[T](action: => T): Boolean =
+    val asyncAction = Future {
+      action
+    }
+    try Await.result(asyncAction, 2000.millisecond)
+    catch case _: Throwable => return true
+    return false
diff --git a/previous-exams/2022-final/concpar22final03.md b/previous-exams/2022-final/concpar22final03.md
new file mode 100644
index 0000000000000000000000000000000000000000..84cea5e6c1d7fb4342b615e54cb7b9fa3d57323e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03.md
@@ -0,0 +1,37 @@
+# Problem 3: Futures
+
+## Setup
+
+Use the following commands to make a fresh clone of your repository:
+
+```
+git clone -b concpar22final03 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final03
+```
+
+If you have issues with the IDE, try [reimporting the
+build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting),
+if you still have problems, use `compile` in sbt instead.
+
+## Exercise
+
+The famous trading cards game Scala: The Programming has recently been gaining traction.  
+In this little exercise, you will propose to clients your services as a trader: Buying and selling cards in groups on demand.
+
+You are provided in the file `Economics.scala` with an interface to handle asynchronous buying and selling of cards and management of your money. Do not modify this file. More precisely, this interface defines:
+
+- A `Card`, which has a `name` and which you can own (`isMine == true`) or not (`isMine == false`). This is only to prevent a card from being sold or used multiple times, and you may not need it. You can find the value of a card using `valueOf`.
+- A `MoneyBag`, which is a container to transport money. Similarly, the money inside a bag can only be used once. The function `moneyIn` informs you of the bag's value, should you need it.
+- The function `sellCard`, which will sell a card through a `Future` and gives you back a `Future[MoneyBag]`. If you do not own the card, the `Future` will fail.
+- The function `buyCard`, which will consume a given `MoneyBag` and handle you, through a `Future`, the requested card. The provided bag must contain the exact amount of money corresponding to the card's value.
+- Finally, you have a bank account with the following functions: 
+	- `balance`: indicates your current monetary possession
+	- `withdraw`: substracts a given amount from your balance, and handles you a corresponding `Future[MoneyBag]`
+	- `deposit`: consumes a moneyBag and returns a `Future[Unit]` when the balance is updated. Note that you should not deposit empty moneyBags! If you do, you will get a failure, possibly indicating that you try to deposit the same bag twice.
+
+Your task in the exercise is to implement the function `orderDeck` in the file `Problem3.scala`. In a `Future`, start by checking that the sum of the money and the value of the cards the client gives you is large enough to buy the requested list of cards. If it is not, then the future should fail with a `NotEnoughMoneyException`.
+
+Then, sell all provided cards and put the received moneyBags in your bank accounts by chaining asynchronously the `Futures` of `sellCard` and `deposit`. You will obtain a `List[Future[Unit]]`, which should be converted into a `Future[Unit]` (so that when this `Future` returns, all deposits have finished). Those steps are provided for you in the helper function `sellListOfCards`.
+
+Then, do the opposite: withdraw `MoneyBags` of adequate value and use them to buy cards. Finally agregate the `List[Future[Card]]` into a `Future[List[Card]]`. You can implement those steps into the `buyListOfCards` function. Take inspiration from the given example `sellListOfCards`, and combine them in the `orderDeck` function.
+
+Final tip: Make good use of `map`, `flatMap` and `zip` on futures.
diff --git a/previous-exams/2022-final/concpar22final03/.gitignore b/previous-exams/2022-final/concpar22final03/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final/concpar22final03/assignment.sbt b/previous-exams/2022-final/concpar22final03/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final/concpar22final03/build.sbt b/previous-exams/2022-final/concpar22final03/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..19eefd46becd98fc415b1c37ed07eb5225b9621e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/build.sbt
@@ -0,0 +1,11 @@
+course := "concpar"
+assignment := "concpar22final03"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final/concpar22final03/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final03/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final/concpar22final03/project/build.properties b/previous-exams/2022-final/concpar22final03/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final/concpar22final03/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final03/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final/concpar22final03/project/plugins.sbt b/previous-exams/2022-final/concpar22final03/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala
new file mode 100644
index 0000000000000000000000000000000000000000..c0327140228a4016e257931a3bfd3d2ca863774d
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala
@@ -0,0 +1,44 @@
+package concpar22final03
+
+import scala.concurrent.Future
+
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import scala.util.Random
+
+trait Economics:
+
+  /** A trading card from the game Scala: The Programming. We can own a card, but once don't
+    * anymore.
+    */
+  final class Card(val name: String)
+  def isMine(c: Card): Boolean
+
+  /** This function uses the best available database to return the sell value of a card on the
+    * market.
+    */
+  def valueOf(cardName: String): Int = List(1, cardName.length).max
+
+  /** This method represents an exact amount of money that can be hold, spent, or put in the bank
+    */
+  final class MoneyBag()
+  def moneyIn(m: MoneyBag): Int
+
+  /** If you sell a card, at some point in the future you will get some money (in a bag).
+    */
+  def sellCard(c: Card): Future[MoneyBag]
+
+  /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate
+    * amount and waiting for the transaction to take place. You will own the returned card.
+    */
+  def buyCard(money: MoneyBag, name: String): Future[Card]
+
+  /** This simple bank account holds money for you. You can bring a money bag to increase your
+    * account's balance, or withdraw a money bag of any size not greater than your account's
+    * balance.
+    */
+  def balance: Int
+  def withdraw(amount: Int): Future[MoneyBag]
+  def deposit(bag: MoneyBag): Future[Unit]
+
+  class NotEnoughMoneyException extends Exception("Not enough money provided to buy those cards")
\ No newline at end of file
diff --git a/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala
new file mode 100644
index 0000000000000000000000000000000000000000..46c5a92fef18324953761770d8c64300b7befd1d
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala
@@ -0,0 +1,38 @@
+package concpar22final03
+
+import scala.concurrent.Future
+import concurrent.ExecutionContext.Implicits.global
+
+trait Problem3:
+  val economics: Economics
+  import economics.*
+
+  /** The objective is to propose a service of deck building. People come to you with some money and
+    * some cards they want to sell, and you need to return them a complete deck of the cards they
+    * want.
+    */
+  def orderDeck(
+      bag: MoneyBag,
+      cardsToSell: List[Card],
+      wantedDeck: List[String]
+  ): Future[List[Card]] =
+    Future {
+      ??? // : Future[List[Card]]
+    }.flatten
+
+  /** This helper function will sell the provided list of cards and put the money on your personal
+    * bank account. It returns a Future of Unit, which indicates when all sales are completed.
+    */
+  def sellListOfCards(cardsToSell: List[Card]): Future[Unit] =
+    val moneyFromSales: List[Future[Unit]] = cardsToSell.map { c =>
+      sellCard(c).flatMap(m => deposit(m).map { _ => })
+    }
+    Future
+      .sequence(moneyFromSales)
+      .map(_ => ()) // Future.sequence transforms a List[Future[A]] into a Future[List[A]]
+
+  /** This helper function, given a list of wanted card names and assuming there is enough money in
+    * the bank account, will buy (in the future) those cards, and return them.
+    */
+  def buyListOfCards(wantedDeck: List[String]): Future[List[Card]] =
+    ???
diff --git a/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a8c327e35bbb55159e329e0f048aa49172accd13
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala
@@ -0,0 +1,98 @@
+package concpar22final03
+
+import scala.concurrent.Future
+
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import scala.util.Random
+
+trait EconomicsTest extends Economics:
+  val ownedCards: collection.mutable.Set[Card] = collection.mutable.Set[Card]()
+  def owned(c: Card): Boolean = ownedCards(c)
+  def isMine(c: Card): Boolean = ownedCards(c)
+
+  override def valueOf(cardName: String): Int = List(1, cardName.length).max
+
+  /** This is a container for an exact amount of money that can be hold, spent, or put in the bank
+    */
+  val moneyInMoneyBag = collection.mutable.Map[MoneyBag, Int]()
+  def moneyIn(m: MoneyBag): Int = moneyInMoneyBag.getOrElse(m, 0)
+
+  /** If you sell a card, at some point in the future you will get some money (in a bag).
+    */
+  def sellCard(c: Card): Future[MoneyBag] =
+    Future {
+      Thread.sleep(sellWaitTime())
+      synchronized(
+        if owned(c) then
+          ownedCards.remove(c)
+          getMoneyBag(valueOf(c.name))
+        else
+          throw Exception(
+            "This card doesn't belong to you or has already been sold, you can't sell it."
+          )
+      )
+    }
+
+  /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate
+    * amount and waiting for the transaction to take place
+    */
+  def buyCard(bag: MoneyBag, name: String): Future[Card] =
+    Future {
+      Thread.sleep(buyWaitTime())
+      synchronized {
+        if moneyIn(bag) != valueOf(name) then
+          throw Exception(
+            "You didn't provide the exact amount of money necessary to buy this card."
+          )
+        else moneyInMoneyBag.update(bag, 0)
+        getCard(name)
+      }
+
+    }
+
+  /** This simple bank account hold money for you. You can bring a money bag to increase your
+    * account, or withdraw a money bag of any size not greater than your account's balance.
+    */
+  private var balance_ = initialBalance()
+  def balance: Int = balance_
+  def withdraw(amount: Int): Future[MoneyBag] =
+    Future {
+      Thread.sleep(withdrawWaitTime())
+      synchronized(
+        if balance_ >= amount then
+          balance_ -= amount
+          getMoneyBag(amount)
+        else
+          throw new Exception(
+            "You try to withdraw more money than you have on your account"
+          )
+      )
+    }
+
+  def deposit(bag: MoneyBag): Future[Unit] =
+    Future {
+      Thread.sleep(depositWaitTime())
+      synchronized {
+        if moneyInMoneyBag(bag) == 0 then throw new Exception("You are depositing en empty bag!")
+        else
+          balance_ += moneyIn(bag)
+          moneyInMoneyBag.update(bag, 0)
+      }
+    }
+
+  def sellWaitTime(): Int
+  def buyWaitTime(): Int
+  def withdrawWaitTime(): Int
+  def depositWaitTime(): Int
+  def initialBalance(): Int
+
+  def getMoneyBag(i: Int) =
+    val m = MoneyBag()
+    synchronized(moneyInMoneyBag.update(m, i))
+    m
+
+  def getCard(n: String): Card =
+    val c = Card(n)
+    synchronized(ownedCards.update(c, true))
+    c
diff --git a/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..a99852a24b5e541093beee0a534b98f3547d1aba
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala
@@ -0,0 +1,201 @@
+package concpar22final03
+
+import scala.concurrent.duration.*
+import scala.concurrent.{Await, Future}
+import scala.util.{Try, Success, Failure}
+import scala.concurrent.ExecutionContext.Implicits.global
+
+class Problem3Suite extends munit.FunSuite:
+  trait Prob3Test extends Problem3:
+    override val economics: EconomicsTest
+  class Test1 extends Prob3Test:
+    override val economics: EconomicsTest = new EconomicsTest:
+      override def sellWaitTime() = 10
+      override def buyWaitTime() = 20
+      override def depositWaitTime() = 30
+      override def withdrawWaitTime() = 40
+      override def initialBalance() = 0
+  class Test2 extends Prob3Test:
+    override val economics: EconomicsTest = new EconomicsTest:
+      override def sellWaitTime() = 100
+      override def buyWaitTime() = 5
+      override def depositWaitTime() = 50
+      override def withdrawWaitTime() = 5
+      override def initialBalance() = 0
+
+  class Test3 extends Prob3Test:
+    override val economics: EconomicsTest = new EconomicsTest:
+      val rgen = new scala.util.Random(666)
+      override def sellWaitTime() = rgen.nextInt(100)
+      override def buyWaitTime() = rgen.nextInt(100)
+      override def depositWaitTime() = rgen.nextInt(100)
+      override def withdrawWaitTime() = rgen.nextInt(100)
+      override def initialBalance() = 0
+
+  class Test4 extends Prob3Test:
+    var counter = 5
+    def next(): Int =
+      counter = counter + 5 % 119
+      counter
+    override val economics: EconomicsTest = new EconomicsTest:
+      override def sellWaitTime() = next()
+      override def buyWaitTime() = next()
+      override def depositWaitTime() = next()
+      override def withdrawWaitTime() = next()
+      override def initialBalance() = next()
+
+  def testCases = List(new Test1, new Test2)
+  def unevenTestCases = List(new Test3, new Test4)
+
+  def tot(cards: List[String]): Int =
+    cards.map[Int]((n: String) => n.length).sum
+
+  def testOk(
+      t: Prob3Test,
+      money: Int,
+      sold: List[String],
+      wanted: List[String]
+  ): Unit =
+    import t.*
+    import economics.*
+    val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted)
+    val r = Await.ready(f, 3.seconds).value.get
+    assert(r.isSuccess)
+    r match
+      case Success(d) =>
+        assertEquals(d.map(_.name).sorted, wanted.sorted)
+        assertEquals(d.length, wanted.length)
+        assertEquals(isMine(d.head), true)
+      case Failure(e) => ()
+
+  def testFailure(
+      t: Prob3Test,
+      money: Int,
+      sold: List[String],
+      wanted: List[String]
+  ): Unit =
+    import t.*
+    import economics.*
+    val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted)
+    val r = Await.ready(f, 3.seconds).value.get
+    assert(r.isFailure)
+    r match
+      case Failure(e: NotEnoughMoneyException) => ()
+      case _ => fail("Should have thrown a NotEnoughMoneyException exception, but did not")
+
+  // --- Without sold cards ---
+
+  test(
+    "Should work correctly when a single card is asked with enough money (no card sold) (20pts)"
+  ) {
+    testCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri")))
+  }
+  test(
+    "Should work correctly when a single card is asked with enough money (no card sold, uneven waiting time) (10pts)"
+  ) {
+    unevenTestCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri")))
+  }
+  test(
+    "Should work correctly when multiple cards are asked with enough money (no card sold) (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    testCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+  test(
+    "Should work correctly when multiple cards are asked with enough money (no card sold, uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+  test(
+    "Should work correctly when asked duplicates of cards, with enough money (no card sold) (20pts)"
+  ) {
+    val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd")
+    testCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+  test(
+    "Should work correctly when asked duplicates of cards, with enough money (no card sold, uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd")
+    unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards))
+  }
+
+  // --- With sold cards ---
+
+  test(
+    "Should work correctly when a single card is bought and a single of the same price is sold (20pts)"
+  ) {
+    testCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri")))
+  }
+  test(
+    "Should work correctly when a single card is bought and a single of the same price is sold (uneven waiting time) (10pts)"
+  ) {
+    unevenTestCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri")))
+  }
+
+  test(
+    "Should work correctly when multiple cards are asked and multiple of matching values are sold (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777")
+    testCases.foreach(t => testOk(t, 0, sold, cards))
+  }
+  test(
+    "Should work correctly when multiple cards are asked and multiple of matching values are sold (uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777")
+    unevenTestCases.foreach(t => testOk(t, 0, sold, cards))
+  }
+  test(
+    "Should work correctly when multiple cards are asked and multiple of the same total value are sold (20pts)"
+  ) {
+    val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777")
+    assert(tot(sold2) == tot(cards2))
+    testCases.foreach(t => testOk(t, 0, sold2, cards2))
+  }
+  test(
+    "Should work correctly when multiple cards are asked and multiple of the same total value are sold (uneven waiting time) (10pts)"
+  ) {
+    val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777")
+    assert(tot(sold2) == tot(cards2))
+    unevenTestCases.foreach(t => testOk(t, 0, sold2, cards2))
+  }
+
+  test(
+    "Should work correctly when given money and sold cards are sufficient for the wanted cards (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("11111", "2", "33", "44", "5555", "666", "777")
+    val bagMoney = tot(cards) - tot(sold)
+    testCases.foreach(t => testOk(t, bagMoney, sold, cards))
+  }
+  test(
+    "Should work correctly when given money and sold cards are sufficient for the wanted cards (uneven waiting time) (10pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("11111", "2", "33", "44", "5555", "666", "777")
+    val bagMoney = tot(cards) - tot(sold)
+    unevenTestCases.foreach(t => testOk(t, bagMoney, sold, cards))
+  }
+
+  // --- Failures ---
+
+  test(
+    "Should return a failure when too little money is provided (no card sold) (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    testCases.foreach(t => testFailure(t, tot(cards) - 1, Nil, cards))
+    testCases.foreach(t => testFailure(t, tot(cards) - 50, Nil, cards))
+  }
+
+  test(
+    "Should return a failure when too little money or sold cards are provided (20pts)"
+  ) {
+    val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg")
+    val sold = List("11111", "2", "33", "44", "5555", "666", "777")
+    val bagMoney = tot(cards) - tot(sold)
+    testCases.foreach(t => testFailure(t, bagMoney - 2, sold, cards))
+  }
diff --git a/previous-exams/2022-final/concpar22final04.md b/previous-exams/2022-final/concpar22final04.md
new file mode 100644
index 0000000000000000000000000000000000000000..0fd39f571bb3c10974a7ff893e5b59e3972542ff
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04.md
@@ -0,0 +1,98 @@
+# Problem 4: Implementing Spotify using actors
+
+## Setup
+
+Use the following commands to make a fresh clone of your repository:
+
+```
+git clone -b concpar22final04 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final04
+```
+
+If you have issues with the IDE, try [reimporting the
+build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting),
+if you still have problems, use `compile` in sbt instead.
+
+## Useful Links
+
+- [Akka Classic documentation](https://doc.akka.io/docs/akka/current/index-classic.html), in particular:
+    - [Classic Actors](https://doc.akka.io/docs/akka/current/actors.html)
+    - [Testing Classic Actors](https://doc.akka.io/docs/akka/current/testing.html)
+    - [Classic Logging](https://doc.akka.io/docs/akka/current/logging.html)
+- [Akka Classic API reference](https://doc.akka.io/api/akka/current/akka/actor/index.html)
+- [CS206 slides](https://gitlab.epfl.ch/lamp/cs206/-/tree/master/slides)
+
+## Overview
+
+In this exercise, you will implement the core functionalities of an online music streaming service. Users will be modelled as individual Akka actors.
+
+- Each user has a unique identifier and a name.
+- Each user can like and unlike songs (stored in the user's _liked songs_ list). Liked songs are sorted by reverse date of liking time (the last liked song must be the first element of the list). Elements of this list must be unique: a song can only be liked once. Liking a song twice should not impact the order.
+- Each user can subscribe and unsubscribe to other users to see what they are listening to. This is stored in the user's _activity feed_. The items in the activity feed are sorted by reverse date of activity time (the last added activity must be the first element of the list). Items in this list should be unique by user id. If a new activity with a user id that is already in the list is added, the former should be removed, so that we always see the latest activity for each user we have subscribed to.
+
+This corresponds to the core features of Spotify:
+
+![](./spotify.jpg)
+
+Your task is to implement the receive method of the `User` actor. See the enums in the `User` what messages and responses a `User` should handle.
+
+You are allowed to add private methods and attributes to the `User` class. You can also import any Scala collection you might find useful.
+
+To implement the last part (problem 4.4), you will need to interact with the `SongStore` actor passed as the `songStore` parameter. You do not need it for the other parts.
+
+## Problem 4.1: Getting user info (50 points)
+
+Your first task is to implement the `User.receive` method so that it handles the `GetInfo` and `GetHomepageData` messages. This will allow you to pass the first 2 tests.
+
+## Problem 4.2: Updating user info (70 points)
+
+Your second task is to expand `User.receive` so that it handles the `Like` and `Unlike` messages.
+
+## Problem 4.3: Updating user info (70 points)
+
+Your third task is to expand `User.receive` so that it handles the `Subscribe`, `Unsubscribe`, `AddActivity` and `Play` messages.
+
+## Problem 4.4: Displaying the homepage (60 points)
+
+Your last (but not least!) task is to expand `User.receive` so that it handles the `GetHomepageText` message.
+
+A `GetHomepageText` should be answered with `HomepageText` message. Here is an example of a `HomepageText.result`:
+
+```
+Howdy Ada!
+
+Liked Songs:
+* Sunny by Boney M.
+* J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+* Hold the line by TOTO
+
+Activity Feed:
+* Bob is listening to Straight Edge by Minor Threat
+* Donald is listening to Désenchantée by Mylène Farmer
+* Carol is listening to Breakfast in America by Supertramp
+```
+
+More precisely, it should contains the following lines in order:
+
+1. `Howdy $name!`, where `$name` is the name of the recipient user.
+2. A blank line
+3. `Liked Songs:`
+4. Zero or more lines listing the user's liked songs. Each of these lines should be of the form `"* ${song.title} by ${song.artist}`, where `${song.title}` is the title of the song and `${song.artist}` its artist.
+5. A blank line
+6. Zero or more lines listing the user activity feed items. Each of these lines should be of the form `* ${user.name} is listening to ${song.title} by ${song.artist}`, where `${user.name}` is the name of the user listening, `${song.title}` is the title of the song and `${song.artist}` its artist.
+
+In order to fetch the songs information (titles and artists), you should use the `songStore` actor passed as an argument to `User`. See the enums in the `SongsStore` companion object to learn how to interact with the song store.
+
+__Hint 1:__ to construct the result, you might find useful to use [`f-strings`](https://docs.scala-lang.org/overviews/core/string-interpolation.html#the-f-interpolator) and the [`List.mkString`](https://www.scala-lang.org/api/2.13.3/scala/collection/immutable/List.html#mkString(sep:String):String) method. Here is an example of how to use them:
+
+```scala
+val fruits = List("Banana", "Apple", "Kiwi")
+val result = f"""Fruits:
+${fruits.map(fruit => f"* ${fruit}").mkString("\n")}"""
+
+assert(result == """Fruits:
+* Banana
+* Apple
+* Kiwi""")
+```
+
+__Hint 2:__ if you need to send the result of a future to an actor, you should use the `pipeTo` method as described in the lectures and [here](https://doc.akka.io/docs/akka/2.5/futures.html#use-the-pipe-pattern).
diff --git a/previous-exams/2022-final/concpar22final04/.gitignore b/previous-exams/2022-final/concpar22final04/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..d094868cd9b4b92462501f0510a8bf72881ec542
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/.gitignore
@@ -0,0 +1,17 @@
+*.DS_Store
+*.swp
+*~
+*.class
+*.tasty
+target/
+logs/
+.bloop
+.bsp
+.dotty-ide-artifact
+.dotty-ide.json
+.idea
+.metals
+.vscode
+*.csv
+*.dat
+metals.sbt
diff --git a/previous-exams/2022-final/concpar22final04/assignment.sbt b/previous-exams/2022-final/concpar22final04/assignment.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..70cbe9572ba3c14a332b13686143444fc71c534a
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/assignment.sbt
@@ -0,0 +1,5 @@
+// Student tasks (i.e. submit, packageSubmission)
+enablePlugins(StudentTasks)
+
+assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1"
+
diff --git a/previous-exams/2022-final/concpar22final04/build.sbt b/previous-exams/2022-final/concpar22final04/build.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..ea5fb5d30451b2de6e1d8916323a3d4cef4657fe
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/build.sbt
@@ -0,0 +1,23 @@
+course := "concpar"
+assignment := "concpar22final04"
+scalaVersion := "3.1.0"
+
+scalacOptions ++= Seq("-language:implicitConversions", "-deprecation")
+libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test
+
+val akkaVersion = "2.6.19"
+val logbackVersion = "1.2.11"
+libraryDependencies ++= Seq(
+    "com.typesafe.akka" %% "akka-actor" % akkaVersion,
+    "com.typesafe.akka" %% "akka-testkit" % akkaVersion,
+    // SLF4J backend
+    // See https://doc.akka.io/docs/akka/current/typed/logging.html#slf4j-backend
+    "ch.qos.logback" % "logback-classic" % logbackVersion
+)
+fork := true
+javaOptions ++= Seq("-Dakka.loglevel=Error", "-Dakka.actor.debug.receive=on")
+
+val MUnitFramework = new TestFramework("munit.Framework")
+testFrameworks += MUnitFramework
+// Decode Scala names
+testOptions += Tests.Argument(MUnitFramework, "-s")
diff --git a/previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala
new file mode 100644
index 0000000000000000000000000000000000000000..0d5da7fa7f25c17bb2a559138862b471f38788bf
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala
@@ -0,0 +1,212 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scala.util.{Failure, Success, Try}
+import scalaj.http._
+import play.api.libs.json.{Json, JsObject, JsPath}
+
+/**
+ * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have
+ * different item ids.
+ *
+ * @param key Assignment key
+ * @param partId Assignment partId
+ * @param itemId Item id of the non premium version
+ * @param premiumItemId Item id of the premium version (`None` if the assignment is optional)
+ */
+case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String])
+
+
+object CourseraStudent extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val options = SettingKey[Map[String, Map[String, String]]]("options")
+    val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment")
+    // Convenient alias
+    type CourseraId = ch.epfl.lamp.CourseraId
+    val CourseraId = ch.epfl.lamp.CourseraId
+  } 
+
+  import StudentTasks.autoImport._
+  import MOOCSettings.autoImport._
+  import autoImport._
+
+  override lazy val projectSettings = Seq(
+    submitSetting,
+  )
+
+  /** Task to submit a solution to coursera */
+  val submit = inputKey[Unit]("submit solution to Coursera")
+  lazy val submitSetting = submit := {
+    // Fail if scalafix linting does not pass.
+    StudentTasks.scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("<arg>").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val assignmentDetails =
+      courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined"))
+    val assignmentKey = assignmentDetails.key
+    val courseName =
+      course.value match {
+        case "progfun1" => "scala-functional-programming"
+        case "progfun2" => "scala-functional-program-design"
+        case "parprog1" => "scala-parallel-programming"
+        case "bigdata"  => "scala-spark-big-data"
+        case "capstone" => "scala-capstone"
+        case "reactive" => "scala-akka-reactive"
+        case other      => other
+      }
+
+    val partId = assignmentDetails.partId
+    val itemId = assignmentDetails.itemId
+    val premiumItemId = assignmentDetails.premiumItemId
+
+    val (email, secret) = args match {
+      case email :: secret :: Nil =>
+        (email, secret)
+      case _ =>
+        val inputErr =
+          s"""|Invalid input to `submit`. The required syntax for `submit` is:
+              |submit <email-address> <submit-token>
+              |
+              |The submit token is NOT YOUR LOGIN PASSWORD.
+              |It can be obtained from the assignment page:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId
+              |${
+                premiumItemId.fold("") { id =>
+                  s"""or (for premium learners):
+                     |https://www.coursera.org/learn/$courseName/programming/$id
+                   """.stripMargin
+                }
+              }
+          """.stripMargin
+        s.log.error(inputErr)
+        StudentTasks.failSubmit()
+    }
+
+    val base64Jar = StudentTasks.prepareJar(jar, s)
+    val json =
+      s"""|{
+          |   "assignmentKey":"$assignmentKey",
+          |   "submitterEmail":"$email",
+          |   "secret":"$secret",
+          |   "parts":{
+          |      "$partId":{
+          |         "output":"$base64Jar"
+          |      }
+          |   }
+          |}""".stripMargin
+
+    def postSubmission[T](data: String): Try[HttpResponse[String]] = {
+      val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1")
+      val hs = List(
+        ("Cache-Control", "no-cache"),
+        ("Content-Type", "application/json")
+      )
+      s.log.info("Connecting to Coursera...")
+      val response = Try(http.postData(data)
+                         .headers(hs)
+                         .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s
+                         .asString) // kick off HTTP POST
+      response
+    }
+
+    val connectMsg =
+      s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course
+          |Using:
+          |- email: $email
+          |- submit token: $secret""".stripMargin
+    s.log.info(connectMsg)
+
+    def reportCourseraResponse(response: HttpResponse[String]): Unit = {
+      val code = response.code
+      val respBody = response.body
+
+       /* Sample JSON response from Coursera
+      {
+        "message": "Invalid email or token.",
+        "details": {
+          "learnerMessage": "Invalid email or token."
+        }
+      }
+      */
+
+      // Success, Coursera responds with 2xx HTTP status code
+      if (response.is2xx) {
+        val successfulSubmitMsg =
+          s"""|Successfully connected to Coursera. (Status $code)
+              |
+                |Assignment submitted successfully!
+              |
+                |You can see how you scored by going to:
+              |https://www.coursera.org/learn/$courseName/programming/$itemId/
+              |${
+            premiumItemId.fold("") { id =>
+              s"""or (for premium learners):
+                 |https://www.coursera.org/learn/$courseName/programming/$id
+                       """.stripMargin
+            }
+          }
+              |and clicking on "My Submission".""".stripMargin
+        s.log.info(successfulSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 4xx HTTP status code (client-side failure)
+      else if (response.is4xx) {
+        val result = Try(Json.parse(respBody)).toOption
+        val learnerMsg = result match {
+          case Some(resp: JsObject) =>
+            (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get
+          case Some(x) => // shouldn't happen
+            "Could not parse Coursera's response:\n" + x
+          case None =>
+            "Could not parse Coursera's response:\n" + respBody
+        }
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |There was something wrong while attempting to submit.
+              |Coursera says:
+              |$learnerMsg (Status $code)""".stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera responds with 5xx HTTP status code (server-side failure)
+      else if (response.is5xx) {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera seems to be unavailable at the moment (Status $code)
+              |Check https://status.coursera.org/ and try again in a few minutes.
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+
+      // Failure, Coursera repsonds with an unexpected status code
+      else {
+        val failedSubmitMsg =
+          s"""|Submission failed.
+              |Coursera replied with an unexpected code (Status $code)
+           """.stripMargin
+        s.log.error(failedSubmitMsg)
+      }
+    }
+
+    // kick it all off, actually make request
+    postSubmission(json) match {
+      case Success(resp) => reportCourseraResponse(resp)
+      case Failure(e) =>
+        val failedConnectMsg =
+          s"""|Connection to Coursera failed.
+              |There was something wrong while attempting to connect to Coursera.
+              |Check your internet connection.
+              |${e.toString}""".stripMargin
+        s.log.error(failedConnectMsg)
+    }
+
+   }
+
+}
diff --git a/previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala
new file mode 100644
index 0000000000000000000000000000000000000000..347cc6e5d59073cf532986c82151cbc44887108f
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala
@@ -0,0 +1,51 @@
+package ch.epfl.lamp
+
+import sbt._
+import sbt.Keys._
+
+/**
+  * Settings shared by all assignments, reused in various tasks.
+  */
+object MOOCSettings extends AutoPlugin {
+
+  object autoImport {
+    val course = SettingKey[String]("course")
+    val assignment = SettingKey[String]("assignment")
+    val datasetUrl = settingKey[String]("URL of the dataset used for testing")
+    val downloadDataset = taskKey[File]("Download the dataset required for the assignment")
+    val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment")
+  }
+
+  import autoImport._
+
+  lazy val downloadDatasetDef = downloadDataset := {
+    val logger = streams.value.log
+
+    datasetUrl.?.value match {
+      case Some(url) => 
+
+        import scalaj.http.Http
+        import sbt.io.IO
+        val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last
+        if (!dest.exists()) {
+          IO.touch(dest)
+          logger.info(s"Downloading $url")
+          val res = Http(url).method("GET")
+          val is = res.asBytes.body
+          IO.write(dest, is)
+        }
+        dest
+      case None => 
+        logger.info(s"No dataset defined in datasetUrl")
+        throw new sbt.MessageOnlyException("No dataset to download for this assignment")
+    }
+  }
+
+  override val projectSettings: Seq[Def.Setting[_]] = Seq(
+    downloadDatasetDef,
+    Test / parallelExecution := false,
+    // Report test result after each test instead of waiting for every test to finish
+    Test / logBuffered := false,
+    name := s"${course.value}-${assignment.value}"
+  )
+}
diff --git a/previous-exams/2022-final/concpar22final04/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final04/project/StudentTasks.scala
new file mode 100644
index 0000000000000000000000000000000000000000..1ae03c11516a13a55e094a936b02fed588c2a9e2
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/project/StudentTasks.scala
@@ -0,0 +1,150 @@
+package ch.epfl.lamp
+
+import sbt._
+import Keys._
+import scalafix.sbt.ScalafixPlugin.autoImport._
+
+import java.io.{File, FileInputStream, IOException}
+import org.apache.commons.codec.binary.Base64
+
+/**
+  * Provides tasks for submitting the assignment
+  */
+object StudentTasks extends AutoPlugin {
+
+  override def requires = super.requires && MOOCSettings
+
+  object autoImport {
+    val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project")
+    val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources")
+
+    val packageSubmissionZip = TaskKey[File]("packageSubmissionZip")
+
+    val packageSubmission = inputKey[Unit]("package solution as an archive file")
+    lazy val Grading = config("grading") extend(Runtime)
+  }
+
+  import autoImport._
+
+  // Run scalafix linting after compilation to avoid seeing parser errors twice
+  // Keep in sync with the use of scalafix in Grader
+  // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795)
+  //  so we customize unmanagedSources below instead)
+  val scalafixLinting = Def.taskDyn {
+    if (new File(".scalafix.conf").exists()) {
+      (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile)
+    } else Def.task(())
+  }
+
+  val testsJar = file("grading-tests.jar")
+
+  override lazy val projectSettings = Seq(
+    // Run scalafix linting in parallel with the tests
+    (Test / test) := {
+      scalafixLinting.value
+      (Test / test).value
+    },
+
+    packageSubmissionSetting,
+
+    fork := true,
+    run / connectInput := true,
+    outputStrategy := Some(StdoutOutput),
+    scalafixConfig := {
+      val scalafixDotConf = (baseDirectory.value / ".scalafix.conf")
+      if (scalafixDotConf.exists) Some(scalafixDotConf) else None
+    }
+  ) ++ packageSubmissionZipSettings ++ (
+    if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq(
+      unmanagedJars += testsJar,
+      definedTests := (Test / definedTests).value,
+      internalDependencyClasspath := (Test / internalDependencyClasspath).value,
+      managedClasspath := (Test / managedClasspath).value,
+    ))
+    else Nil
+  )
+
+  val packageSubmissionZipSettings = Seq(
+    packageSubmissionZip := {
+      val submission = crossTarget.value / "submission.zip"
+      val sources = (Compile / packageSourcesOnly).value
+      val binaries = (Compile / packageBinWithoutResources).value
+      IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None)
+      submission
+    },
+    packageSourcesOnly / artifactClassifier := Some("sources"),
+    Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources"))
+  ) ++
+  inConfig(Compile)(
+    Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++
+    Defaults.packageTaskSettings(packageBinWithoutResources, Def.task {
+      val relativePaths =
+        (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_))
+      (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) }
+    })
+  )
+
+  val maxSubmitFileSize = {
+    val mb = 1024 * 1024
+    10 * mb
+  }
+
+  def prepareJar(jar: File, s: TaskStreams): String = {
+    val errPrefix = "Error submitting assignment jar: "
+    val fileLength = jar.length()
+    if (!jar.exists()) {
+      s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength == 0L) {
+      s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath)
+      failSubmit()
+    } else if (fileLength > maxSubmitFileSize) {
+      s.log.error(errPrefix + "jar archive is too big. Allowed size: " +
+        maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" +
+        jar.getAbsolutePath)
+      failSubmit()
+    } else {
+      val bytes = new Array[Byte](fileLength.toInt)
+      val sizeRead = try {
+        val is = new FileInputStream(jar)
+        val read = is.read(bytes)
+        is.close()
+        read
+      } catch {
+        case ex: IOException =>
+          s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString)
+          failSubmit()
+      }
+      if (sizeRead != bytes.length) {
+        s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead)
+        failSubmit()
+      } else encodeBase64(bytes)
+    }
+  }
+
+  /** Task to package solution to a given file path */
+  lazy val packageSubmissionSetting = packageSubmission := {
+    // Fail if scalafix linting does not pass.
+    scalafixLinting.value
+
+    val args: Seq[String] = Def.spaceDelimited("[path]").parsed
+    val s: TaskStreams = streams.value // for logging
+    val jar = (Compile / packageSubmissionZip).value
+
+    val base64Jar = prepareJar(jar, s)
+
+    val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath)
+    scala.tools.nsc.io.File(path).writeAll(base64Jar)
+  }
+
+  def failSubmit(): Nothing = {
+    sys.error("Submission failed")
+  }
+
+  /**
+    * *****************
+    * DEALING WITH JARS
+    */
+  def encodeBase64(bytes: Array[Byte]): String =
+    new String(Base64.encodeBase64(bytes))
+}
diff --git a/previous-exams/2022-final/concpar22final04/project/build.properties b/previous-exams/2022-final/concpar22final04/project/build.properties
new file mode 100644
index 0000000000000000000000000000000000000000..3161d2146c631009a4d731d13510aeaddc9cf47e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/project/build.properties
@@ -0,0 +1 @@
+sbt.version=1.6.1
diff --git a/previous-exams/2022-final/concpar22final04/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final04/project/buildSettings.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..1d987356b002ed0ad6c32492aa0bf2532370edf7
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/project/buildSettings.sbt
@@ -0,0 +1,5 @@
+// Used for Coursera submission (StudentPlugin)
+libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2"
+libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2"
+// Used for Base64 (StudentPlugin)
+libraryDependencies += "commons-codec" % "commons-codec" % "1.15"
\ No newline at end of file
diff --git a/previous-exams/2022-final/concpar22final04/project/plugins.sbt b/previous-exams/2022-final/concpar22final04/project/plugins.sbt
new file mode 100644
index 0000000000000000000000000000000000000000..3c7aad8964d825963f87341033685db13d1000d5
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/project/plugins.sbt
@@ -0,0 +1,2 @@
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26")
+addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8")
diff --git a/previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala b/previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala
new file mode 100644
index 0000000000000000000000000000000000000000..5e54d55c7073ac39afd18edb0c25c184f26b23db
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala
@@ -0,0 +1,179 @@
+package concpar22final04
+
+import akka.actor.*
+import akka.testkit.*
+import java.util.Date
+import akka.event.LoggingReceive
+import akka.pattern.*
+import akka.util.Timeout
+import concurrent.duration.*
+import scala.concurrent.Future
+import scala.concurrent.ExecutionContext
+
+given timeout: Timeout = Timeout(200.millis)
+
+/** Data associated with a song: a unique `id`, a `title` and an `artist`.
+  */
+case class Song(id: Int, title: String, artist: String)
+
+/** An activity in a user's activity feed, representing that `userRef` is
+  * listening to `songId`.
+  */
+case class Activity(userId: String, userName: String, songId: Int)
+
+/** Companion object of the `User` class.
+  */
+object User:
+  /** Messages that can be sent to User actors.
+    */
+  enum Protocol:
+    /** Asks for a user name and id. Should be answered by a Response.Info.
+      */
+    case GetInfo
+
+    /** Asks home page data. Should be answered by a Response.HomepageData.
+      */
+    case GetHomepageData
+
+    /** Like song with id `songId`.
+      */
+    case Like(songId: Int)
+
+    /** Unlike song with id `songId`.
+      */
+    case Unlike(songId: Int)
+
+    /** Adds `subscriber` to the list of subscribers.
+      */
+    case Subscribe(subscriber: ActorRef)
+
+    /** Remove `subscriber` from the list of subscribers.
+      */
+    case Unsubscribe(subscriber: ActorRef)
+
+    /** Adds the activity `activity` to the activity feed. This message will be
+      * sent by the users this user has subscribed to.
+      */
+    case AddActivity(activity: Activity)
+
+    /** Sent when a user starts playing a song with id `songId`. The recipient
+      * should notify all its subscribers to update their activity feeds by
+      * sending them `AddActivity(Activity(...))` messages. No answer is
+      * expected. This message is sent by external actors.
+      */
+    case Play(songId: Int)
+
+    /** Asks for home page text. Should be answered by a Response.HomepageText.
+      */
+    case GetHomepageText
+
+  /** Responses that can be sent back from User actors.
+    */
+  enum Responses:
+    /** Answer to a Protocol.GetInfo message
+      */
+    case Info(id: String, name: String)
+
+    /** Answer to a Protocol.GetHomepageData message
+      */
+    case HomepageData(songIds: List[Int], activities: List[Activity])
+
+    /** Answer to a Protocol.GetHomepageText message
+      */
+    case HomepageText(result: String)
+
+/** The `User` actor, responsible to handle `User.Protocol` messages.
+  */
+class User(id: String, name: String, songsStore: ActorRef) extends Actor:
+  import User.*
+  import User.Protocol.*
+  import User.Responses.*
+  import SongsStore.Protocol.*
+  import SongsStore.Responses.*
+
+  given ExecutionContext = context.system.dispatcher
+
+  /** Liked songs, by reverse date of liking time (the last liked song must
+    * be the first must be the first element of the list). Elements of this
+    * list must be unique: a song can only be liked once. Liking a song
+    * twice should not change the order.
+    */
+  var likedSongs: List[Int] = List()
+
+  /** Users who have subscribed to this users.
+    */
+  var subscribers: Set[ActorRef] = Set()
+
+  /** Activity feed, by reverse date of activity time (the last added
+    * activity must be the first element of the list). Items in this list
+    * should be unique by `userRef`. If a new activity with a `userRef`
+    * already in the list is added, the former should be removed, so that we
+    * always see the latest activity for each user we have subscribed to.
+    */
+  var activityFeed: List[Activity] = List()
+
+
+  /** This actor's behavior. */
+  override def receive: Receive = ???
+
+/** Objects containing the messages a songs store should handle.
+  */
+object SongsStore:
+  /** Ask information about a list of songs by their ids.
+    */
+  enum Protocol:
+    case GetSongs(songIds: List[Int])
+
+  /** List of `Song` corresponding to the list of IDs given to `GetSongs`.
+    */
+  enum Responses:
+    case Songs(songs: List[Song])
+
+/** A mock implementation of a songs store.
+  */ 
+class MockSongsStore extends Actor:
+  import SongsStore.Protocol.*
+  import SongsStore.Responses.*
+  import SongsStore.*
+
+  val songsDB = Map(
+    1 -> Song(1, "High Hopes", "Pink Floyd"),
+    2 -> Song(2, "Sunny", "Boney M."),
+    3 -> Song(3, "J'irai où tu iras", "Céline Dion & Jean-Jacques Goldman"),
+    4 -> Song(4, "Ce monde est cruel", "Vald"),
+    5 -> Song(5, "Strobe", "deadmau5"),
+    6 -> Song(6, "Désenchantée", "Mylène Farmer"),
+    7 -> Song(7, "Straight Edge", "Minor Threat"),
+    8 -> Song(8, "Hold the line", "TOTO"),
+    9 -> Song(9, "Anarchy in the UK", "Sex Pistols"),
+    10 -> Song(10, "Breakfast in America", "Supertramp")
+  )
+
+  override def receive: Receive = LoggingReceive { case GetSongs(songsIds) =>
+    sender() ! Songs(songsIds.map(songsDB))
+  }
+
+/////////////////////////
+//        DEBUG        //
+/////////////////////////
+
+/** Infrastructure to help debugging. In sbt use `run` to execute this code.
+ *  The TestKit is an actor that can send messages and check the messages it receives (or not).
+ */
+@main def debug() = new TestKit(ActorSystem("DebugSystem")) with ImplicitSender:
+  import User.*
+  import User.Protocol.*
+  import User.Responses.*
+  import SongsStore.Protocol.*
+  import SongsStore.Responses.*
+
+  try
+    val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore")
+    val anita = system.actorOf(Props(User("100", "Anita", songsStore)))
+
+    anita ! Like(6)
+    expectNoMessage() // expects no message is received
+
+    anita ! GetHomepageData
+    expectMsg(HomepageData(List(6), List()))
+  finally shutdown(system)
diff --git a/previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala b/previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala
new file mode 100644
index 0000000000000000000000000000000000000000..f88d129333e3732434e22c1c1c65acf22d006a3e
--- /dev/null
+++ b/previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala
@@ -0,0 +1,361 @@
+package concpar22final04
+
+import akka.actor.*
+import akka.testkit.*
+import akka.pattern.*
+import akka.util.Timeout
+import concurrent.duration.*
+import User.Protocol.*
+import User.Responses.*
+import SongsStore.Protocol.*
+import SongsStore.Responses.*
+import scala.util.{Try, Success, Failure}
+import com.typesafe.config.ConfigFactory
+import java.util.Date
+import scala.util.Random
+
+class Problem4Suite extends munit.FunSuite:
+//---
+  Random.setSeed(42178263)
+/*+++
+  Random.setSeed(42)
+++*/
+
+  test("after receiving GetInfo, should answer with Info (20pts)") {
+    new MyTestKit:
+      def tests() =
+        ada ! GetInfo
+        expectMsg(Info("1", "Ada"))
+  }
+
+  test("after receiving GetHomepageData, should answer with the correct HomepageData when there is no liked songs and no activity items (30pts)") {
+    new MyTestKit:
+      def tests() =
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(), List()))
+  }
+
+  test("after receiving Like(1), should add 1 to the list of liked songs (20pts)") {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(1), List()))
+  }
+
+  test(
+    "after receiving Like(1) and then Like(2), the list of liked songs should start with List(2, 1) (20pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(2, 1), List()))
+  }
+
+  test(
+    "after receiving Like(1) and then Like(1), song 1 should be in the list of liked songs only once (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(1), List()))
+  }
+
+  test(
+    "after receiving Like(1), Like(2) and then Like(1), the list of liked songs should start with List(2, 1) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(2, 1), List()))
+  }
+
+  test(
+    "after receiving Like(1), Unlike(1) and then Unlike(1), the list of liked songs should not contain song 1 (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(2, 1), List()))
+  }
+
+  test(
+    "after receiving Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (20pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+  }
+
+  test(
+    "after receiving Subscribe(aUser), Subscribe(bUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        val donald = new TestProbe(system)
+        ada ! Subscribe(donald.ref)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+        donald.expectMsg(AddActivity(Activity("1", "Ada", 5)))
+  }
+
+  test(
+    "after receiving Subscribe(aUser), Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser only once (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+        expectNoMessage()
+  }
+
+  test(
+    "after receiving Subscribe(aUser), Unsubscribe(aUser) and then Play(5), should not send AddActivity(Activity(\"1\", 5)) to aUser (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Subscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectMsg(AddActivity(Activity("1", "Ada", 5)))
+        ada ! Unsubscribe(self)
+        expectNoMessage()
+        ada ! Play(5)
+        expectNoMessage()
+  }
+
+  test(
+    "after receiving AddActivity(Activity(\"1\", 5)), Activity(\"1\", 5) should be in the activity feed (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! AddActivity(Activity("0", "Self", 5))
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(), List(Activity("0", "Self", 5))))
+  }
+
+  test(
+    "after receiving AddActivity(Activity(\"1\", 5)) and AddActivity(Activity(\"1\", 6)), Activity(\"1\", 6) should be in the activity feed and Activity(\"1\", 5) should not (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! AddActivity(Activity("0", "Self", 5))
+        expectNoMessage()
+        ada ! AddActivity(Activity("0", "Self", 6))
+        expectNoMessage()
+        ada ! GetHomepageData
+        expectMsg(HomepageData(List(), List(Activity("0", "Self", 6))))
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with a result containing \"Howdy $name!\" where $name is the user's name (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        val name = Random.alphanumeric.take(5).mkString
+        val randomUser = system.actorOf(Props(classOf[User], "5", name, songsStore), "user-5")
+        randomUser ! GetHomepageText
+        expectMsgClass(classOf[HomepageText]).result.contains(f"Howdy $name!")
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct names of liked songs (1) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(8)
+        expectNoMessage()
+        ada ! Like(3)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(2)
+            .take(4)
+            .mkString("\n")
+            .trim,
+          """
+          |Liked Songs:
+          |* Sunny by Boney M.
+          |* J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+          |* Hold the line by TOTO
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct names of liked songs (2) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(9)
+        expectNoMessage()
+        ada ! Like(7)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(2)
+            .take(3)
+            .mkString("\n")
+            .trim,
+          """
+          |Liked Songs:
+          |* Straight Edge by Minor Threat
+          |* Anarchy in the UK by Sex Pistols
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct activity feed (1) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        bob ! Subscribe(ada)
+        expectNoMessage()
+        carol ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Subscribe(ada)
+        expectNoMessage()
+        bob ! Play(3)
+        expectNoMessage()
+        carol ! Play(8)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(4)
+            .take(10)
+            .mkString("\n")
+            .trim,
+          """
+          |Activity Feed:
+          |* Carol is listening to Hold the line by TOTO
+          |* Bob is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct activity feed (2) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        bob ! Subscribe(ada)
+        expectNoMessage()
+        carol ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Subscribe(ada)
+        expectNoMessage()
+        bob ! Play(9)
+        expectNoMessage()
+        carol ! Play(10)
+        expectNoMessage()
+        donald ! Play(6)
+        expectNoMessage()
+        bob ! Play(7)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .drop(4)
+            .take(10)
+            .mkString("\n")
+            .trim,
+          """
+          |Activity Feed:
+          |* Bob is listening to Straight Edge by Minor Threat
+          |* Donald is listening to Désenchantée by Mylène Farmer
+          |* Carol is listening to Breakfast in America by Supertramp
+          """.stripMargin.trim
+        )
+  }
+
+  test(
+    "after receiving GetHomepageText, should answer with the correct text (full test) (10pts)"
+  ) {
+    new MyTestKit:
+      def tests() =
+        ada ! Like(1)
+        expectNoMessage()
+        ada ! Like(2)
+        expectNoMessage()
+        bob ! Subscribe(ada)
+        expectNoMessage()
+        carol ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Subscribe(ada)
+        expectNoMessage()
+        donald ! Play(3)
+        expectNoMessage()
+        bob ! Play(4)
+        expectNoMessage()
+        carol ! Play(5)
+        expectNoMessage()
+        ada ! GetHomepageText
+        assertEquals(
+          expectMsgClass(classOf[HomepageText]).result.linesIterator
+            .mkString("\n")
+            .trim,
+          """
+          |Howdy Ada!
+          |
+          |Liked Songs:
+          |* Sunny by Boney M.
+          |* High Hopes by Pink Floyd
+          |
+          |Activity Feed:
+          |* Carol is listening to Strobe by deadmau5
+          |* Bob is listening to Ce monde est cruel by Vald
+          |* Donald is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman
+          """.stripMargin.trim
+        )
+  }
+
+  abstract class MyTestKit
+      extends TestKit(ActorSystem("TestSystem"))
+      with ImplicitSender:
+    val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore")
+    def makeAda() = system.actorOf(Props(classOf[User], "1", "Ada", songsStore), "user-1")
+    val ada = makeAda()
+    val bob = system.actorOf(Props(classOf[User], "2", "Bob", songsStore), "user-2")
+    val carol = system.actorOf(Props(classOf[User], "3", "Carol", songsStore), "user-3")
+    val donald = system.actorOf(Props(classOf[User], "4", "Donald", songsStore), "user-4")
+    def tests(): Unit
+    try tests()
+    finally shutdown(system)
diff --git a/previous-exams/2022-final/spotify.jpg b/previous-exams/2022-final/spotify.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..79f91ad367a09ab3348ff0c167eaa9ddbdba8f37
Binary files /dev/null and b/previous-exams/2022-final/spotify.jpg differ