{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "permanence-000",
   "metadata": {},
   "source": [
    "**Run the following cell before doing anything else** and then click the blue bar to the left of the cell to hide it, if it isn't already. Do **not** edit it!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-001",
   "metadata": {
    "deletable": false,
    "editable": false,
    "init_cell": true,
    "jupyter": {
     "source_hidden": true
    },
    "tags": [
     "hide-input",
     "init_cell",
     "tutorial-setup"
    ]
   },
   "outputs": [],
   "source": [
    "from IPython import get_ipython\n",
    "from IPython.display import HTML, Javascript, clear_output, display\n",
    "import hashlib\n",
    "import hmac\n",
    "import html\n",
    "import json\n",
    "\n",
    "_TUTORIAL_CHECK_ORDER = [\n",
    "    \"1.0.1\", \"2.1.1\", \"2.2.1\", \"3.1.1\", \"3.2.1\", \"3.3.1\",\n",
    "    \"4.1.1\", \"4.2.1\", \"4.3.1\", \"5.1.1\", \"5.2.1\", \"5.3.1\",\n",
    "]\n",
    "_TUTORIAL_PROGRESS_IDS = {}\n",
    "_TUTORIAL_CHECK_BRANCHES = {}\n",
    "_TUTORIAL_TEXT_CHECKS = {\"1.0.1\", \"2.2.1\", \"5.3.1\"}\n",
    "_TUTORIAL_OPTIONAL_NOTES = {\"6.1.1\", \"6.2.1\", \"6.3.1\"}\n",
    "_TUTORIAL_EXACT_CHECKS = {'2.1.1': ('df0f6cb136d6874d245a28cbb55b987dd0d045a36fb16c418bf3c18b2624a669', 'Run uv run nessie-watch in the new project and paste only its greeting. Check the project name in uv init.'), '3.1.1': ('f0d69d042343494fc3a09193f818c8cec3a84065aa561941a54df905ccb46e4c', 'Check all six instance attributes, then copy the four lines from check_sighting.py in order.'), '3.2.1': ('ff71043cfe80663d336ff76325e9cb783f307fbe7f2f0be844a6d12ec5608535', 'Check the __str__ format, >= 60, and >= 2. Both conditions must hold. Paste all five lines.'), '3.3.1': ('dde2296978ed2bbb87eb72b3fc2d3d64ea93a58fe4ddf8604c969817d48fc6d1', 'alias refers to first; independent is a new instance. Paste only the final three Boolean lines.'), '4.1.1': ('8a57c61b169e6f88c109fddf10781f1508e2d08beaf65f5037f2941ed5b34a1f', 'Read the supplied CSV with DictReader, convert the numeric fields, append every object, and return after the loop. Copy all four lines.'), '4.2.1': ('f5513672ee9d190f8f4bdcf604e1e4641eaf3e622130ef475b9a2713dff07a3f', 'Keep file order, use needs_visit(), and build a new list. Copy the count, all sighting lines, and the original archive count.'), '4.3.1': ('7e2f5720566d2ea0781262b3856b6cd659e6de3e4939c35aa8293417b841b542', 'Keep the first report when scores tie, sum all recorded witnesses, and display the mean to two decimal places. Check the empty-list case too.'), '5.1.1': ('d5238e0a379c736b6d13e26a30d1b90d68c17db469522cd731a2868bedc7b74f', 'Count every location and keep locations in first-occurrence order. Paste only the six location lines.'), '5.2.1': ('9bc00a43dc9aba6e05133b20a357e8d941b8fa066a1b6bd975d295d7a0e1d1f7', 'Copy the complete saved text file, including the heading and blank line before Reports by location:. Check labels, order, and decimal places.')}\n",
    "_TUTORIAL_STARTER_ANSWERS = {\n",
    "    check_id: \"PASTE OUTPUT HERE\" for check_id in _TUTORIAL_CHECK_ORDER\n",
    "}\n",
    "_TUTORIAL_STARTER_ANSWERS.update({\n",
    "    \"1.0.1\": \"WRITE NAMES HERE\",\n",
    "    \"2.2.1\": \"WRITE NOTES HERE\",\n",
    "    \"5.2.1\": \"PASTE FILE CONTENTS HERE\",\n",
    "    \"5.3.1\": \"WRITE NOTES HERE\",\n",
    "    \"6.1.1\": \"WRITE NOTES HERE\",\n",
    "    \"6.2.1\": \"WRITE NOTES HERE\",\n",
    "    \"6.3.1\": \"WRITE NOTES HERE\",\n",
    "})\n",
    "_TUTORIAL_SUCCESS_MESSAGES = {\n",
    "    \"1.0.1\": (\"Got it.\", \"The field station has its team. Let's open the archive!\"),\n",
    "    \"2.1.1\": (\"Correct!\", \"Your field station project is ready.\"),\n",
    "    \"2.2.1\": (\"Notes recorded.\", \"You are ready to put the project's modules to work.\"),\n",
    "    \"3.1.1\": (\"Correct!\", \"Each sighting has its own attributes.\"),\n",
    "    \"3.2.1\": (\"Correct!\", \"The follow-up rule passes its boundary checks.\"),\n",
    "    \"3.3.1\": (\"Correct!\", \"Two names can refer to the same sighting.\"),\n",
    "    \"4.1.1\": (\"Correct!\", \"The archive has become a list of sighting objects.\"),\n",
    "    \"4.2.1\": (\"Correct!\", \"The boat crew has its shortlist.\"),\n",
    "    \"4.3.1\": (\"Correct!\", \"The summary keeps the first report when scores tie.\"),\n",
    "    \"5.1.1\": (\"Correct!\", \"Every report is counted under its location.\"),\n",
    "    \"5.2.1\": (\"Correct!\", \"The report has been saved for the next shift.\"),\n",
    "    \"5.3.1\": (\"Notes recorded.\", \"You have completed the core tutorial! Choose any bonus task if there is time.\"),\n",
    "}\n",
    "\n",
    "# Only inherit progress when this setup belongs to the same tutorial.\n",
    "# A different notebook may have used this kernel before us.\n",
    "_tutorial_same_identity = (\n",
    "    globals().get(\"_TUTORIAL_IDENTITY\") == \"tutorial-4-object-permanence\"\n",
    "    and getattr(globals().get(\"_tutorial_check\"), \"_tutorial_identity\", None) == \"tutorial-4-object-permanence\"\n",
    ")\n",
    "_previous_completed = set(globals().get(\"_TUTORIAL_COMPLETED\", set())) if _tutorial_same_identity else set()\n",
    "_TUTORIAL_IDENTITY = \"tutorial-4-object-permanence\"\n",
    "_TUTORIAL_COMPLETED = set()\n",
    "for _check_id in _TUTORIAL_CHECK_ORDER:\n",
    "    if _check_id not in _previous_completed:\n",
    "        break\n",
    "    _TUTORIAL_COMPLETED.add(_check_id)\n",
    "_TUTORIAL_COMPLETED.update(_previous_completed & _TUTORIAL_OPTIONAL_NOTES)\n",
    "_TUTORIAL_SELECTIONS = {}\n",
    "\n",
    "\n",
    "def _tutorial_progress_id(check_id):\n",
    "    return _TUTORIAL_PROGRESS_IDS.get(check_id, check_id)\n",
    "\n",
    "\n",
    "def _tutorial_reset_progress(expected_identity=\"tutorial-4-object-permanence\"):\n",
    "    if expected_identity != _TUTORIAL_IDENTITY:\n",
    "        return False\n",
    "    _TUTORIAL_COMPLETED.clear()\n",
    "    _TUTORIAL_SELECTIONS.clear()\n",
    "    return True\n",
    "\n",
    "\n",
    "def _tutorial_normalize(value):\n",
    "    return \"\\n\".join(line.rstrip() for line in value.strip().splitlines())\n",
    "\n",
    "\n",
    "def _tutorial_digest(value):\n",
    "    return hashlib.sha256(value.encode(\"utf-8\")).hexdigest()\n",
    "\n",
    "\n",
    "def _tutorial_message(kind, title, message):\n",
    "    colours = {\n",
    "        \"correct\": (\"#166534\", \"#dcfce7\", \"#86efac\"),\n",
    "        \"incorrect\": (\"#991b1b\", \"#fef2f2\", \"#fca5a5\"),\n",
    "    }\n",
    "    text, background, border = colours[kind]\n",
    "    display(HTML(\n",
    "        f'<div style=\"margin:8px 0;padding:10px 12px;border-left:4px solid {border};'\n",
    "        f'background:{background};color:{text}\">'\n",
    "        f'<strong>{html.escape(title)}</strong> {html.escape(message)}</div>'\n",
    "    ))\n",
    "\n",
    "\n",
    "def _tutorial_check(line, cell, _identity=\"tutorial-4-object-permanence\"):\n",
    "    clear_output(wait=True)\n",
    "    if _identity != _TUTORIAL_IDENTITY:\n",
    "        _tutorial_message(\"incorrect\", \"Run this tutorial's setup first.\", \"This kernel is currently checking a different tutorial.\")\n",
    "        return\n",
    "    check_id = line.strip()\n",
    "    progress_id = _tutorial_progress_id(check_id)\n",
    "    answer = _tutorial_normalize(cell)\n",
    "    known_checks = set(_TUTORIAL_CHECK_ORDER) | _TUTORIAL_OPTIONAL_NOTES\n",
    "\n",
    "    if check_id not in known_checks:\n",
    "        _tutorial_message(\"incorrect\", \"This check could not run.\", \"Please ask your tutor for help.\")\n",
    "        return\n",
    "\n",
    "    placeholders = tuple(value.upper() for value in _TUTORIAL_STARTER_ANSWERS.values())\n",
    "    if not answer or any(placeholder in answer.upper() for placeholder in placeholders):\n",
    "        _tutorial_message(\"incorrect\", \"No answer yet.\", \"Replace the placeholder, then run the cell again.\")\n",
    "        return\n",
    "\n",
    "    if progress_id in _TUTORIAL_CHECK_ORDER:\n",
    "        position = _TUTORIAL_CHECK_ORDER.index(progress_id)\n",
    "        missing = [\n",
    "            earlier for earlier in _TUTORIAL_CHECK_ORDER[:position]\n",
    "            if earlier not in _TUTORIAL_COMPLETED\n",
    "        ]\n",
    "        if missing:\n",
    "            _tutorial_message(\"incorrect\", \"Complete the previous step first.\", \"Return to the last visible check above.\")\n",
    "            return\n",
    "\n",
    "    correct = False\n",
    "    hint = \"Check your answer carefully and try again.\"\n",
    "    completion_details = {}\n",
    "\n",
    "    if check_id in _TUTORIAL_EXACT_CHECKS:\n",
    "        expected_digest, hint = _TUTORIAL_EXACT_CHECKS[check_id]\n",
    "        correct = hmac.compare_digest(_tutorial_digest(answer), expected_digest)\n",
    "    elif check_id in _TUTORIAL_TEXT_CHECKS or check_id in _TUTORIAL_OPTIONAL_NOTES:\n",
    "        correct = bool(answer)\n",
    "        hint = \"Write something before running the cell.\"\n",
    "\n",
    "    if not correct:\n",
    "        _tutorial_message(\"incorrect\", \"Not quite.\", hint)\n",
    "        return\n",
    "\n",
    "    already_completed = progress_id in _TUTORIAL_COMPLETED\n",
    "    if progress_id in _TUTORIAL_CHECK_ORDER and not already_completed:\n",
    "        position = _TUTORIAL_CHECK_ORDER.index(progress_id)\n",
    "        for later_id in _TUTORIAL_CHECK_ORDER[position + 1:]:\n",
    "            _TUTORIAL_COMPLETED.discard(later_id)\n",
    "        completion_details[\"resetFollowing\"] = True\n",
    "\n",
    "    _TUTORIAL_COMPLETED.add(progress_id)\n",
    "\n",
    "    if check_id in _TUTORIAL_OPTIONAL_NOTES:\n",
    "        _tutorial_message(\"correct\", \"Notes recorded.\", \"You can continue with any bonus task.\")\n",
    "        return\n",
    "\n",
    "    title, message = _TUTORIAL_SUCCESS_MESSAGES.get(check_id, (\"Correct!\", \"\"))\n",
    "    _tutorial_message(\"correct\", title, message)\n",
    "    display(Javascript(\n",
    "        \"window.__tutorialProgress && window.__tutorialProgress.complete(\"\n",
    "        + json.dumps(check_id)\n",
    "        + \", \"\n",
    "        + json.dumps(completion_details)\n",
    "        + \");\"\n",
    "    ))\n",
    "\n",
    "\n",
    "_tutorial_check._tutorial_identity = _TUTORIAL_IDENTITY\n",
    "_tutorial_shell = get_ipython()\n",
    "if _tutorial_shell is None:\n",
    "    raise RuntimeError(\"Run this cell with a Python Jupyter kernel.\")\n",
    "_tutorial_shell.register_magic_function(_tutorial_check, \"cell\", \"check\")\n",
    "_tutorial_message(\"correct\", \"Setup complete.\", \"The field station's tutorial checks are ready.\")\n",
    "\n",
    "\n",
    "_tutorial_progress_javascript = r\"\"\"\n",
    "(() => {\n",
    "  const tutorialIdentity = \"tutorial-4-object-permanence\";\n",
    "  const checkOrder = __CHECK_ORDER__;\n",
    "  const progressIds = __PROGRESS_IDS__;\n",
    "  const checkBranches = __CHECK_BRANCHES__;\n",
    "  const optionalNotes = __OPTIONAL_NOTES__;\n",
    "  const completedFromKernel = __COMPLETED__;\n",
    "  const selectionsFromKernel = __SELECTIONS__;\n",
    "  const hiddenClass = \"tutorial-progress-hidden\";\n",
    "  const styleId = \"tutorial-progress-style\";\n",
    "  const starterAnswers = __STARTER_ANSWERS__;\n",
    "\n",
    "  if (!document.getElementById(styleId)) {\n",
    "    const style = document.createElement(\"style\");\n",
    "    style.id = styleId;\n",
    "    style.textContent = `.${hiddenClass} { display: none !important; }`;\n",
    "    document.head.appendChild(style);\n",
    "  }\n",
    "\n",
    "  const previous = window.__tutorialProgress;\n",
    "  if (previous && previous.observer) previous.observer.disconnect();\n",
    "\n",
    "  const state = {\n",
    "    completed: new Set(completedFromKernel),\n",
    "    selections: { ...selectionsFromKernel },\n",
    "    observer: null,\n",
    "    scheduled: false,\n",
    "  };\n",
    "\n",
    "  function progressId(id) {\n",
    "    return progressIds[id] || id;\n",
    "  }\n",
    "\n",
    "  function visibleNotebook() {\n",
    "    const scriptNotebook = document.currentScript &&\n",
    "      document.currentScript.closest(\".jp-Notebook, #notebook-container\");\n",
    "    if (scriptNotebook) return scriptNotebook;\n",
    "    const candidates = [...document.querySelectorAll(\".jp-Notebook, #notebook-container\")];\n",
    "    return candidates.find((node) => node.offsetParent !== null) || candidates[0] || null;\n",
    "  }\n",
    "\n",
    "  function currentNotebookPanel() {\n",
    "    const apps = [\n",
    "      window.jupyterapp,\n",
    "      window.jupyterlab,\n",
    "      window.jupyterLab,\n",
    "      window.jupyterFrontEnd,\n",
    "      window.jupyterApp,\n",
    "    ];\n",
    "    for (const app of apps) {\n",
    "      const widget = app && app.shell && app.shell.currentWidget;\n",
    "      if (widget && widget.content && widget.content.model && widget.context) return widget;\n",
    "    }\n",
    "    return null;\n",
    "  }\n",
    "\n",
    "  state.notebook = visibleNotebook();\n",
    "\n",
    "  if (!state.notebook) {\n",
    "    const warning = document.createElement(\"div\");\n",
    "    warning.style.cssText = \"margin:8px 0;padding:10px 12px;border-left:4px solid #f59e0b;background:#fffbeb;color:#92400e;font-family:system-ui,sans-serif\";\n",
    "    warning.textContent = \"The checks are ready, but this notebook frontend does not allow automatic section reveal. The checks will still mark answers, but all sections may remain visible.\";\n",
    "    document.body.appendChild(warning);\n",
    "    return;\n",
    "  }\n",
    "\n",
    "  state.notebook.__tutorialProgress = state;\n",
    "\n",
    "  function cells() {\n",
    "    const selector = state.notebook.id === \"notebook-container\"\n",
    "      ? \":scope > .cell\"\n",
    "      : \".jp-Notebook-cell\";\n",
    "    return [...state.notebook.querySelectorAll(selector)].filter(\n",
    "      (cell) => !cell.closest(\".jp-Notebook\") || cell.closest(\".jp-Notebook\") === state.notebook\n",
    "    );\n",
    "  }\n",
    "\n",
    "  function source(cell) {\n",
    "    const classicEditor = cell.querySelector(\".CodeMirror\");\n",
    "    if (classicEditor && classicEditor.CodeMirror) return classicEditor.CodeMirror.getValue();\n",
    "    const modernEditor = cell.querySelector(\".cm-content\");\n",
    "    if (modernEditor) return modernEditor.innerText || modernEditor.textContent || \"\";\n",
    "    const input = cell.querySelector(\".input_area, .jp-InputArea-editor\");\n",
    "    return input ? (input.innerText || input.textContent || \"\") : \"\";\n",
    "  }\n",
    "\n",
    "  function checkId(cell) {\n",
    "    const match = source(cell).match(/^%%check\\s+([a-z0-9_.-]+)/m);\n",
    "    return match ? match[1] : null;\n",
    "  }\n",
    "\n",
    "  function renderedMarker(cell) {\n",
    "    return cell.querySelector(\"[data-tutorial-section]\");\n",
    "  }\n",
    "\n",
    "  function explicitSectionNumber(cell) {\n",
    "    const sectionMarker = renderedMarker(cell);\n",
    "    if (sectionMarker) return Number(sectionMarker.dataset.tutorialSection);\n",
    "    const markerMatch = source(cell).match(/data-tutorial-section=[\\\"'](\\d+)[\\\"']/);\n",
    "    if (markerMatch) return Number(markerMatch[1]);\n",
    "    const id = checkId(cell);\n",
    "    const progress = id ? progressId(id) : null;\n",
    "    const checkIndex = progress ? checkOrder.indexOf(progress) : -1;\n",
    "    if (checkIndex >= 0) return checkIndex;\n",
    "    if (id && optionalNotes.includes(id)) return checkOrder.length;\n",
    "    return null;\n",
    "  }\n",
    "\n",
    "  function sectionNumber(cell, notebookCells) {\n",
    "    const ownSection = explicitSectionNumber(cell);\n",
    "    if (ownSection !== null) return ownSection;\n",
    "    const index = notebookCells.indexOf(cell);\n",
    "    for (let cursor = index - 1; cursor >= 0; cursor -= 1) {\n",
    "      const inheritedSection = explicitSectionNumber(notebookCells[cursor]);\n",
    "      if (inheritedSection !== null) return inheritedSection;\n",
    "    }\n",
    "    return 0;\n",
    "  }\n",
    "\n",
    "  function branch(cell) {\n",
    "    const sectionMarker = renderedMarker(cell);\n",
    "    if (sectionMarker && sectionMarker.dataset.tutorialOs) {\n",
    "      return {\n",
    "        software: sectionMarker.dataset.tutorialSoftware,\n",
    "        os: sectionMarker.dataset.tutorialOs,\n",
    "      };\n",
    "    }\n",
    "    const id = checkId(cell);\n",
    "    if (id && checkBranches[id]) return checkBranches[id];\n",
    "    const cellSource = source(cell);\n",
    "    const software = cellSource.match(/data-tutorial-software=[\\\"']([^\\\"']+)[\\\"']/);\n",
    "    const os = cellSource.match(/data-tutorial-os=[\\\"']([^\\\"']+)[\\\"']/);\n",
    "    return software && os ? { software: software[1], os: os[1] } : null;\n",
    "  }\n",
    "\n",
    "  function refresh() {\n",
    "    state.scheduled = false;\n",
    "    let unlockedSection = 0;\n",
    "    for (const id of checkOrder) {\n",
    "      if (!state.completed.has(id)) break;\n",
    "      unlockedSection += 1;\n",
    "    }\n",
    "    const notebookCells = cells();\n",
    "    for (const cell of notebookCells) {\n",
    "      const requiredBranch = branch(cell);\n",
    "      const wrongBranch = requiredBranch &&\n",
    "        state.selections[requiredBranch.software] !== requiredBranch.os;\n",
    "      const locked = sectionNumber(cell, notebookCells) > unlockedSection;\n",
    "      cell.classList.toggle(hiddenClass, Boolean(locked || wrongBranch));\n",
    "    }\n",
    "    bindResetButtons();\n",
    "    window.dispatchEvent(new Event(\"resize\"));\n",
    "  }\n",
    "\n",
    "  function scheduleRefresh() {\n",
    "    if (state.scheduled) return;\n",
    "    state.scheduled = true;\n",
    "    requestAnimationFrame(refresh);\n",
    "  }\n",
    "\n",
    "  state.complete = (id, details = {}) => {\n",
    "    const progress = progressId(id);\n",
    "    if (!checkOrder.includes(progress)) return;\n",
    "    const position = checkOrder.indexOf(progress);\n",
    "    if (details.resetFollowing) {\n",
    "      for (const laterId of checkOrder.slice(position + 1)) state.completed.delete(laterId);\n",
    "    }\n",
    "    state.completed.add(progress);\n",
    "    if (details.software && details.os) state.selections[details.software] = details.os;\n",
    "    refresh();\n",
    "  };\n",
    "\n",
    "\n",
    "  function starterSource(id) {\n",
    "    return `%%check ${id}\\n${starterAnswers[id]}`;\n",
    "  }\n",
    "\n",
    "  function modelSource(cell) {\n",
    "    if (cell.sharedModel && cell.sharedModel.getSource) return cell.sharedModel.getSource();\n",
    "    if (cell.value && typeof cell.value.text === \"string\") return cell.value.text;\n",
    "    return \"\";\n",
    "  }\n",
    "\n",
    "  function clearDomOutput(cellNode) {\n",
    "    for (const output of cellNode.querySelectorAll(\".output, .output_wrapper, .jp-OutputArea, .jp-Cell-outputArea\")) {\n",
    "      output.replaceChildren();\n",
    "    }\n",
    "  }\n",
    "\n",
    "  function resetDomCheckCells() {\n",
    "    let resetCount = 0;\n",
    "    for (const cellNode of cells()) {\n",
    "      const id = checkId(cellNode);\n",
    "      if (!id || !starterAnswers[id]) continue;\n",
    "      const sourceText = starterSource(id);\n",
    "      const classicEditor = cellNode.querySelector(\".CodeMirror\");\n",
    "      if (classicEditor && classicEditor.CodeMirror) {\n",
    "        classicEditor.CodeMirror.setValue(sourceText);\n",
    "        clearDomOutput(cellNode);\n",
    "        resetCount += 1;\n",
    "      }\n",
    "    }\n",
    "    return resetCount;\n",
    "  }\n",
    "\n",
    "  async function resetNotebookCells() {\n",
    "    const classicNotebook = window.Jupyter && window.Jupyter.notebook;\n",
    "    if (classicNotebook && classicNotebook.get_cells) {\n",
    "      for (const cell of classicNotebook.get_cells()) {\n",
    "        const match = cell.get_text().match(/^%%check\\s+([a-z0-9_.-]+)/m);\n",
    "        if (!match || !starterAnswers[match[1]]) continue;\n",
    "        cell.set_text(starterSource(match[1]));\n",
    "        if (cell.clear_output) cell.clear_output();\n",
    "        if (cell.set_input_prompt) cell.set_input_prompt(null);\n",
    "      }\n",
    "      classicNotebook.set_dirty(true);\n",
    "      await classicNotebook.save_notebook();\n",
    "      return;\n",
    "    }\n",
    "\n",
    "    const panel = currentNotebookPanel();\n",
    "    const notebookModel = panel && panel.content && panel.content.model;\n",
    "    if (notebookModel && notebookModel.cells) {\n",
    "      for (let index = 0; index < notebookModel.cells.length; index += 1) {\n",
    "        const cell = notebookModel.cells.get(index);\n",
    "        const match = modelSource(cell).match(/^%%check\\s+([a-z0-9_.-]+)/m);\n",
    "        if (!match || !starterAnswers[match[1]]) continue;\n",
    "        if (cell.sharedModel && cell.sharedModel.setSource) {\n",
    "          cell.sharedModel.setSource(starterSource(match[1]));\n",
    "        } else if (cell.value && typeof cell.value.text === \"string\") {\n",
    "          cell.value.text = starterSource(match[1]);\n",
    "        }\n",
    "        if (cell.outputs && cell.outputs.clear) cell.outputs.clear();\n",
    "        if (\"executionCount\" in cell) cell.executionCount = null;\n",
    "      }\n",
    "      await panel.context.save();\n",
    "      return;\n",
    "    }\n",
    "\n",
    "    if (resetDomCheckCells() > 0) return;\n",
    "\n",
    "    throw new Error(\"This notebook frontend does not expose a writable notebook model.\");\n",
    "  }\n",
    "\n",
    "  async function resetKernelProgress() {\n",
    "    const code = \"_tutorial_reset_progress(\\\"\" + tutorialIdentity + \"\\\")\";\n",
    "    try {\n",
    "      const classicKernel = window.Jupyter && window.Jupyter.notebook && window.Jupyter.notebook.kernel;\n",
    "      if (classicKernel && classicKernel.execute) {\n",
    "        classicKernel.execute(code, {}, { silent: true, store_history: false });\n",
    "        return true;\n",
    "      }\n",
    "\n",
    "      const panel = currentNotebookPanel();\n",
    "      const kernel = panel && panel.sessionContext && panel.sessionContext.session &&\n",
    "        panel.sessionContext.session.kernel;\n",
    "      if (kernel && kernel.requestExecute) {\n",
    "        const future = kernel.requestExecute({ code, silent: true, store_history: false });\n",
    "        await future.done;\n",
    "        return true;\n",
    "      }\n",
    "    } catch (error) {\n",
    "      console.error(\"Could not reset tutorial kernel state\", error);\n",
    "    }\n",
    "    return false;\n",
    "  }\n",
    "\n",
    "  async function resetProgress(button) {\n",
    "    const confirmed = window.confirm(\n",
    "      \"Reset all tutorial progress? Every answer and check result will be cleared.\"\n",
    "    );\n",
    "    if (!confirmed) return;\n",
    "\n",
    "    button.disabled = true;\n",
    "    button.textContent = \"Resetting…\";\n",
    "    try {\n",
    "      await resetNotebookCells();\n",
    "      const kernelWasReset = await resetKernelProgress();\n",
    "      state.completed.clear();\n",
    "      state.selections = {};\n",
    "      refresh();\n",
    "      state.notebook.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n",
    "      button.disabled = false;\n",
    "      button.textContent = \"Reset all tutorial progress\";\n",
    "      if (kernelWasReset) {\n",
    "        window.alert(\"Tutorial progress has been reset.\");\n",
    "      } else {\n",
    "        window.alert(\n",
    "          \"Answers were reset. Restart the kernel before beginning again to clear its saved progress too.\"\n",
    "        );\n",
    "      }\n",
    "    } catch (error) {\n",
    "      console.error(\"Could not reset tutorial progress\", error);\n",
    "      window.alert(`The tutorial could not be reset: ${error.message}`);\n",
    "      button.disabled = false;\n",
    "      button.textContent = \"Reset all tutorial progress\";\n",
    "    }\n",
    "  }\n",
    "\n",
    "  function bindResetButtons() {\n",
    "    for (const button of state.notebook.querySelectorAll(\"[data-tutorial-reset]\")) {\n",
    "      if (button.dataset.tutorialResetBound) continue;\n",
    "      button.dataset.tutorialResetBound = \"true\";\n",
    "      button.addEventListener(\"click\", () => state.notebook.__tutorialProgress.reset(button));\n",
    "    }\n",
    "  }\n",
    "\n",
    "  state.reset = resetProgress;\n",
    "  state.observer = new MutationObserver(scheduleRefresh);\n",
    "  state.observer.observe(state.notebook, { childList: true, subtree: true });\n",
    "  window.__tutorialProgress = state;\n",
    "  refresh();\n",
    "  setTimeout(refresh, 100);\n",
    "  setTimeout(refresh, 500);\n",
    "  setTimeout(refresh, 1500);\n",
    "})();\n",
    "\"\"\".replace(\"__STARTER_ANSWERS__\", json.dumps(_TUTORIAL_STARTER_ANSWERS)).replace(\"__CHECK_ORDER__\", json.dumps(_TUTORIAL_CHECK_ORDER)).replace(\n",
    "    \"__PROGRESS_IDS__\", json.dumps(_TUTORIAL_PROGRESS_IDS)\n",
    ").replace(\n",
    "    \"__CHECK_BRANCHES__\", json.dumps(_TUTORIAL_CHECK_BRANCHES)\n",
    ").replace(\n",
    "    \"__OPTIONAL_NOTES__\", json.dumps(sorted(_TUTORIAL_OPTIONAL_NOTES))\n",
    ").replace(\n",
    "    \"__COMPLETED__\", json.dumps(sorted(_TUTORIAL_COMPLETED))\n",
    ").replace(\n",
    "    \"__SELECTIONS__\", json.dumps(_TUTORIAL_SELECTIONS)\n",
    ")\n",
    "\n",
    "display(Javascript(_tutorial_progress_javascript))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-002",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"0\" hidden></span>\n",
    "\n",
    "# Tutorial 4: Object Permanence\n",
    "\n",
    "<div class=\"alert alert-info\">\n",
    "Week 6 | Author: Rayo Verweij (<code>rayo.verweij@ed.ac.uk</code>)<br>\n",
    "</div>\n",
    "\n",
    "Welcome back! Last time, we explored packages and virtual environments, ending up with a project managed by uv. Since then, you've had a more formal introduction to modules and file handling in the lectures, as well as had Assignment 2 released. Today, we're going to practice the techniques necessary for the assignment by tackling one of Scotland's most pressing administrative problems: keeping track of the Loch Ness Monster.\n",
    "\n",
    "You have been appointed the newest programmers at the **Loch Ness Field Station**, an organisation with a modest budget but an unusually optimistic archivist. Reports of Nessie keep arriving, and the station would like a program to read them, decide which ones deserve a follow-up visit, and write a summary that will still exist after the program has stopped running.\n",
    "\n",
    "Whether the monster exists when nobody is looking is beyond the scope of this course. Whether your data still exists after you close Python... that one we can do something about.\n",
    "\n",
    "---\n",
    "\n",
    "## 1 Introductions\n",
    "\n",
    "Find yourself a partner! As before, try to work with someone at about the **same skill level**. Both of you should write and run the code on your own laptops, although you only need to fill out one copy of this tutorial sheet. Write both your names below.\n",
    "\n",
    "<div class=\"alert alert-info\">\n",
    "<b>ℹ️ Checking your work</b>\n",
    "\n",
    "As before, replace the placeholder in each checker cell and run it using the ▶️ button or Ctrl+Enter. Leave its <code>%%check</code> line in place as this is how the notebook identifies individual cells. Paste only the output or file contents requested, without the command you typed or the terminal prompt.\n",
    "</div>"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 1.0.1\n",
    "WRITE NAMES HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-004",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"1\" hidden></span>\n",
    "\n",
    "## 2 Getting started\n",
    "\n",
    "### 2.1 Set up a uv project\n",
    "\n",
    "Open your `Intro to Programming` folder in VS Code and open a new terminal. If an environment from the last tutorial is still active, leave it using `deactivate`. If you are on Windows, make sure you are again using **Command Prompt** rather than PowerShell.\n",
    "\n",
    "Run these commands, one line at a time:\n",
    "\n",
    "```text\n",
    "mkdir tutorial-4\n",
    "cd tutorial-4\n",
    "uv init --name nessie-watch\n",
    "```\n",
    "\n",
    "`uv init` creates a project, as before, and the `--name` flag gives the project the name `nessie-watch`.\n",
    "\n",
    "If `uv` is not recognised, revisit the installation instructions in Tutorial 3 with your tutor. Otherwise, open the new `tutorial-4` folder using **File > Open Folder...** and open a new terminal there.\n",
    "\n",
    "Take a look at the files uv has created:\n",
    "\n",
    "| Name | Purpose |\n",
    "| --- | --- |\n",
    "| `src/nessie_watch/` | The package in which we'll write our Python modules. |\n",
    "| `src/nessie_watch/__init__.py` | The code Python runs when this package is first imported. uv has put a sample `main()` function in here. |\n",
    "| `pyproject.toml` | The project description, including its dependencies and the command that will start our program. |\n",
    "| `.python-version` | The Python version selected for this project. |\n",
    "| `README.md` | A place to describe your project for someone else. |\n",
    "\n",
    "Unlike last time, **keep the `src` folder and the `[project.scripts]` and `[build-system]` sections** in `pyproject.toml`. Now that we've covered modules in more detail, we'll actually use them as intended.\n",
    "\n",
    "Under `[project.scripts]`, you should find:\n",
    "\n",
    "```toml\n",
    "nessie-watch = \"nessie_watch:main\"\n",
    "```\n",
    "\n",
    "This makes `nessie-watch` a command that imports the `nessie_watch` package and calls its `main()` function.\n",
    "\n",
    "Open `src/nessie_watch/__init__.py`. If the function starts with `def main() -> None:`, you can simplify that to `def main():`. The arrow is a **type annotation**, which we'll cover later in the course. It doesn't change how this function runs.\n",
    "\n",
    "From the terminal in `tutorial-4`, run:\n",
    "\n",
    "```text\n",
    "uv run nessie-watch\n",
    "```\n",
    "\n",
    "On its first run, uv creates `.venv`, records the setup in `uv.lock`, and makes your own package available in that environment. You don't need to activate the environment yourself. As before, leave `.venv` and `uv.lock` alone, as they are both managed by uv.\n",
    "\n",
    "Paste the greeting printed by the sample program below to verify that everything is working correctly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 2.1.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-006",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"2\" hidden></span>\n",
    "\n",
    "### 2.2 Meet the evidence\n",
    "\n",
    "Download [nessie_sightings.csv](nessie_sightings.csv) and put it in the **top level of `tutorial-4`**, alongside `pyproject.toml`, rather than inside `src` or `.venv`. Open it in VS Code to take a look.\n",
    "\n",
    "The file is a **CSV**, short for *comma-separated values*. Each row is a report, and the first row gives the names of its fields:\n",
    "\n",
    "| Field | Meaning |\n",
    "| --- | --- |\n",
    "| `report_id` | A unique reference for this report. |\n",
    "| `date` | The reported date, written as `YYYY-MM-DD`. |\n",
    "| `location` | The part of the loch from which the report came. |\n",
    "| `likelihood` | The archivist's opinion of how Nessie-like the report sounds, from 0 to 100. |\n",
    "| `witnesses` | The number of people said to have seen it. |\n",
    "| `description` | What they claim happened. |\n",
    "\n",
    "Read a few descriptions with your partner. Which report would you investigate first, and why? Do you agree with the likelihood scores given by the archivist? Record a report ID and your reasoning below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 2.2.1\n",
    "WRITE NOTES HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-008",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"3\" hidden></span>\n",
    "\n",
    "## 3 Objects in the loch\n",
    "\n",
    "### 3.1 A class of its own\n",
    "\n",
    "Each report contains the same kinds of information. Rather than keeping six separate lists and hoping that their positions continue to match, let's define a class that keeps the information for one sighting together.\n",
    "\n",
    "Create a file named `models.py` **inside `src/nessie_watch`**. In it, start a `Sighting` class with this constructor:\n",
    "\n",
    "```python\n",
    "class Sighting:\n",
    "    def __init__(self, report_id, date, location, likelihood, witnesses, description):\n",
    "        self.report_id = report_id\n",
    "        pass  # Store each of the other five arguments as an instance attribute\n",
    "```\n",
    "\n",
    "Replace `pass` with the five assignments needed. Remember from Lecture 7 that `self` refers to the particular instance being created.\n",
    "\n",
    "Next, create `check_sighting.py` in the **top level of `tutorial-4`**, alongside the data file, with this code:\n",
    "\n",
    "```python\n",
    "from nessie_watch.models import Sighting\n",
    "\n",
    "first = Sighting(\"TEST-1\", \"2026-07-01\", \"Dores\", 60, 2, \"A suspicious ripple.\")\n",
    "second = Sighting(\"TEST-2\", \"2026-07-02\", \"Foyers\", 95, 1, \"An unusually confident duck.\")\n",
    "\n",
    "print(first.report_id)\n",
    "print(first.location)\n",
    "print(first.likelihood + 1)\n",
    "print(second.witnesses)\n",
    "```\n",
    "\n",
    "While we will write our package code in the `src` folder, we'll use this file for quick tests, verifying our work as we go along. The import goes through our package, `nessie_watch`, into its `models` module, and picks out the `Sighting` class.\n",
    "\n",
    "Run the test file from your terminal:\n",
    "\n",
    "```text\n",
    "uv run check_sighting.py\n",
    "```\n",
    "\n",
    "Paste its four output lines below. If Python reports that your object has no attributes, check that you wrote `__init__` with **two underscores on each side**, and that you used `self` in the assignments."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 3.1.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-010",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"4\" hidden></span>\n",
    "\n",
    "### 3.2 Should we get the boat out?\n",
    "\n",
    "The field station has a rule: a report deserves a follow-up visit when its likelihood is **at least 60** and it has **at least two witnesses**. The budget does not stretch to visiting every suspicious duck.\n",
    "\n",
    "In your `Sighting` class, add a method `needs_visit()` that **returns** the result of this rule as a Boolean. This should use the instance's attributes, rather than taking the likelihood and witness count as extra arguments.\n",
    "\n",
    "Then add `__str__()`, returning a readable description in this format:\n",
    "\n",
    "```text\n",
    "TEST-1 | 2026-07-01 | Dores | likelihood: 60/100 | witnesses: 2\n",
    "```\n",
    "\n",
    "Next, replace the four `print` lines in `check_sighting.py` with:\n",
    "\n",
    "```python\n",
    "third = Sighting(\"TEST-3\", \"2026-07-03\", \"Dores\", 59, 5, \"Five people saw something. Probably.\")\n",
    "fourth = Sighting(\"TEST-4\", \"2026-07-04\", \"Dores\", 100, 2, \"The archivist has already packed.\")\n",
    "\n",
    "print(first)\n",
    "print(first.needs_visit())\n",
    "print(second.needs_visit())\n",
    "print(third.needs_visit())\n",
    "print(fourth.needs_visit())\n",
    "```\n",
    "\n",
    "Predict the five lines before you run the file, then paste the actual output below. These test sightings are examples of **boundary tests**: checking both sides of the likelihood boundary and a high-scoring report with only one witness."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-011",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 3.2.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-012",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"5\" hidden></span>\n",
    "\n",
    "### 3.3 Mutable evidence\n",
    "\n",
    "An amendment has arrived: one of the two witnesses to `TEST-1` was, apparently, a dog. We should update the report.\n",
    "\n",
    "After the existing code in `check_sighting.py`, add:\n",
    "\n",
    "```python\n",
    "alias = first\n",
    "independent = Sighting(\"TEST-5\", \"2026-07-01\", \"Dores\", 60, 2, \"A separate report.\")\n",
    "\n",
    "first.witnesses = 1\n",
    "print(first.needs_visit())\n",
    "print(alias.needs_visit())\n",
    "print(independent.needs_visit())\n",
    "```\n",
    "\n",
    "Before running it, discuss which values you expect to change. Does assigning `first` to another variable create another sighting? What about calling `Sighting(...)` again?\n",
    "\n",
    "Run the file and paste **only the final three lines** below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-013",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 3.3.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-014",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"6\" hidden></span>\n",
    "\n",
    "## 4 Opening the archive\n",
    "\n",
    "### 4.1 From text to objects\n",
    "\n",
    "Our class works, but typing every sighting into Python would somewhat defeat the purpose of having a file. Let's write a function that reads it for us.\n",
    "\n",
    "Create `archive.py` inside `src/nessie_watch`. At the top, add:\n",
    "\n",
    "```python\n",
    "import csv\n",
    "from nessie_watch.models import Sighting\n",
    "```\n",
    "\n",
    "`csv` is part of Python's **standard library**, so you don't need `uv add` to use it. It handles working with csv files and their peculiar details such as commas inside quoted descriptions. Look back at the first report: the comma after `jetty` is part of the description, rather than the start of another field. Having a module to take care of these cases rather than having to write our own code that handles them all is very convenient.\n",
    "\n",
    "Below the imports, use this scaffold to write `load_sightings(path)`:\n",
    "\n",
    "```python\n",
    "def load_sightings(path):\n",
    "    sightings = []\n",
    "    with open(path, \"r\", encoding=\"utf-8\", newline=\"\") as file:\n",
    "        reader = csv.DictReader(file)\n",
    "        for row in reader:\n",
    "            sighting = Sighting(\n",
    "                row[\"report_id\"],\n",
    "                row[\"date\"],\n",
    "                row[\"location\"],\n",
    "                int(row[\"likelihood\"]),\n",
    "                int(row[\"witnesses\"]),\n",
    "                row[\"description\"],\n",
    "            )\n",
    "            pass  # Append this sighting to the list\n",
    "    pass  # Return the completed list\n",
    "```\n",
    "\n",
    "`csv.DictReader` reads the header, then gives us **one dictionary per row**, with the field names as keys. All its values start out as strings, so we use `int()` to convert the two numeric fields. Each call to `Sighting(...)` creates a separate instance, and we'll collect those instances in our `sightings` list.\n",
    "\n",
    "The file is opened using `with`, as in Lecture 10, so it is closed when we leave that block. `newline=\"\"` lets the CSV module handle the file's line endings itself; the [module's documentation](https://docs.python.org/3/library/csv.html) recommends this when opening CSV files.\n",
    "\n",
    "Replace the two `pass` lines, paying attention to their indentation.\n",
    "\n",
    "Then create `check_archive.py` in the **top level** of the project:\n",
    "\n",
    "```python\n",
    "from pathlib import Path\n",
    "from nessie_watch.archive import load_sightings\n",
    "\n",
    "sightings = load_sightings(Path(\"nessie_sightings.csv\"))\n",
    "print(\"Reports loaded:\", len(sightings))\n",
    "print(\"First report:\", sightings[0].report_id)\n",
    "print(\"Last report:\", sightings[-1].report_id)\n",
    "print(sightings[0].description)\n",
    "```\n",
    "\n",
    "Run it with `uv run check_archive.py` and paste the four lines below. The final line should be the description itself, with its comma and without the CSV quotation marks.\n",
    "\n",
    "<div class=\"alert alert-info\">\n",
    "<b>ℹ️ Where is Python looking?</b>\n",
    "\n",
    "The relative path <code>Path(\"nessie_sightings.csv\")</code> is interpreted from the terminal's <b>working directory</b>, rather than from the location of <code>archive.py</code>. Keep running commands from <code>tutorial-4</code>. If you get <code>FileNotFoundError</code>, check the working directory with <code>pwd</code> (macOS/Linux) or <code>cd</code> (Windows), and check where you saved the CSV.\n",
    "</div>"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-015",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 4.1.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-016",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"7\" hidden></span>\n",
    "\n",
    "### 4.2 A list for the boat crew\n",
    "\n",
    "Now that we have a list of objects, let's put their method to work. In `archive.py`, write `visits_to_make(sightings)`:\n",
    "\n",
    "1. Create an empty list for the reports that need a visit.\n",
    "2. Loop through the supplied sightings in their existing order.\n",
    "3. Call each sighting's `needs_visit()` method. If it returns `True`, append that sighting to the new list.\n",
    "4. Return the new list after the loop.\n",
    "\n",
    "The function should **return objects**, rather than print them or return just their IDs. That way, the caller can still use their descriptions, dates, and other attributes. It should also leave the original list alone.\n",
    "\n",
    "In `check_archive.py`, add `visits_to_make` to the import from `nessie_watch.archive`, then replace the four `print` lines with:\n",
    "\n",
    "```python\n",
    "visits = visits_to_make(sightings)\n",
    "print(\"Follow-up visits:\", len(visits))\n",
    "for sighting in visits:\n",
    "    print(sighting)\n",
    "print(\"Reports still in archive:\", len(sightings))\n",
    "```\n",
    "\n",
    "Run the file and paste **all** its output below. If some reports have disappeared from the archive, check that your function builds a new list rather than removing items from the old one."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-017",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 4.2.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-018",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"8\" hidden></span>\n",
    "\n",
    "### 4.3 The most enthusiastic report\n",
    "\n",
    "The archivist would like to know which report has the highest likelihood score. In `archive.py`, write `strongest_report(sightings)` to return that `Sighting` object.\n",
    "\n",
    "Use a loop to keep track of the strongest candidate seen so far:\n",
    "\n",
    "1. If the list is empty, return `None` immediately.\n",
    "2. Otherwise, start with the first sighting as your candidate.\n",
    "3. Loop through the sightings. Replace the candidate only when another sighting has a **strictly higher** score.\n",
    "4. Return the candidate after the loop.\n",
    "\n",
    "Why strictly higher? The archivist has requested that, if two reports share the highest score, we keep the one that appears **first in the file**.\n",
    "\n",
    "Next, replace the contents of `check_archive.py` with:\n",
    "\n",
    "```python\n",
    "from pathlib import Path\n",
    "from statistics import mean\n",
    "from nessie_watch.archive import load_sightings, strongest_report\n",
    "\n",
    "sightings = load_sightings(Path(\"nessie_sightings.csv\"))\n",
    "best = strongest_report(sightings)\n",
    "scores = []\n",
    "witness_total = 0\n",
    "for sighting in sightings:\n",
    "    pass  # Append its likelihood to scores\n",
    "    pass  # Add its witnesses to witness_total\n",
    "\n",
    "print(\"Strongest report:\", best.report_id)\n",
    "print(f\"Recorded likelihood: {best.likelihood}/100\")\n",
    "print(\"Recorded witnesses:\", witness_total)\n",
    "print(f\"Mean recorded likelihood: {mean(scores):.2f}/100\")\n",
    "print(\"Empty archive:\", strongest_report([]))\n",
    "```\n",
    "\n",
    "Finish the two accumulator lines. Then run the file and paste its five output lines below.\n",
    "\n",
    "Our witness total adds the counts in the reports. It does not establish how many **different people** visited the loch: the same person could have witnessed more than one sighting. What information would we need to distinguish those cases?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-019",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 4.3.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-020",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"9\" hidden></span>\n",
    "\n",
    "## 5 Object permanence\n",
    "\n",
    "### 5.1 Location reports\n",
    "\n",
    "Before writing our report to disk, let's add a breakdown by location. In `archive.py`, write `location_summary(sightings)`, which returns a **single string** containing one line per location, such as `Dores: 4`, ending each line with `\\n`.\n",
    "\n",
    "Here's an algorithm to follow:\n",
    "\n",
    "1. Build a list of the different location names. Loop through the sightings, appending a location only when it is not already in the list. This keeps locations in the order they first appear in the file.\n",
    "2. Start an empty string to hold your summary.\n",
    "3. For each location in that list, start a counter at zero, then loop through **all** the sightings to count how many match it.\n",
    "4. Add a line to the summary using `f\"{location}: {count}\\n\"`.\n",
    "5. Return the completed string after the loops.\n",
    "\n",
    "This is not the most efficient algorithm, as it involves revisiting the same sightings quite a few times. If you'd like to improve that later, there's a bonus task for that.\n",
    "\n",
    "Replace `check_archive.py` with this shorter check:\n",
    "\n",
    "```python\n",
    "from pathlib import Path\n",
    "from nessie_watch.archive import load_sightings, location_summary\n",
    "\n",
    "sightings = load_sightings(Path(\"nessie_sightings.csv\"))\n",
    "print(location_summary(sightings))\n",
    "```\n",
    "\n",
    "Run it and paste the location lines below, in their printed order. You don't need to include the empty line at the end."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-021",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 5.1.1\n",
    "PASTE OUTPUT HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-022",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"10\" hidden></span>\n",
    "\n",
    "### 5.2 For eternity\n",
    "\n",
    "Well done! We can now read the file, work with its contents as objects, and produce a useful summary. But once Python stops running, its variables are gone. Let's save our findings somewhere a colleague could read them tomorrow.\n",
    "\n",
    "Replace the contents of `src/nessie_watch/__init__.py` with this scaffold:\n",
    "\n",
    "```python\n",
    "from pathlib import Path\n",
    "import statistics\n",
    "\n",
    "from nessie_watch.archive import (\n",
    "    load_sightings,\n",
    "    visits_to_make,\n",
    "    strongest_report,\n",
    "    location_summary,\n",
    ")\n",
    "\n",
    "\n",
    "def main():\n",
    "    sightings = load_sightings(Path(\"nessie_sightings.csv\"))\n",
    "    visits = visits_to_make(sightings)\n",
    "    best = strongest_report(sightings)\n",
    "\n",
    "    scores = []\n",
    "    witness_total = 0\n",
    "    for sighting in sightings:\n",
    "        pass  # Reuse your two accumulator lines from section 4.3\n",
    "\n",
    "    report = \"LOCH NESS FIELD STATION\\n\"\n",
    "    pass  # Add the remaining report lines described below\n",
    "\n",
    "    output_path = Path(\"nessie_report.txt\")\n",
    "    pass  # Write report to this path as UTF-8 text, using with\n",
    "\n",
    "    print(report)\n",
    "    print(\"Saved nessie_report.txt\")\n",
    "```\n",
    "\n",
    "Imports written across several lines in parentheses work just like a one-line import; we've just spread these out to keep the line readable. The imported functions each have their own job, while `main()` puts them together, following our discussion of **separation of concerns** in the lectures.\n",
    "\n",
    "Finish the `pass` sections. After the heading already supplied, build `report` with these lines in order:\n",
    "\n",
    "| Line | What to insert after the label |\n",
    "| --- | --- |\n",
    "| `Reports: ` | The number of loaded sightings. |\n",
    "| `Follow-up visits: ` | The number returned by `visits_to_make`. |\n",
    "| `Recorded witnesses: ` | Your witness total. |\n",
    "| `Mean recorded likelihood: ` | The mean score to two decimal places, followed by `/100`. |\n",
    "| `Strongest report: ` | The strongest report's ID. |\n",
    "| An empty line | A second newline after the previous line. |\n",
    "| `Reports by location:` | Just the heading, followed by a newline. |\n",
    "\n",
    "Finally, add the string returned by `location_summary(sightings)`. End the file with a newline. Use mode `\"w\"` to write it, as running the program again should **replace** the previous report rather than add another copy underneath it. You may assume that the supplied archive contains at least one report, so the mean and the strongest report are available.\n",
    "\n",
    "Save all your files, then run:\n",
    "\n",
    "```text\n",
    "uv run nessie-watch\n",
    "```\n",
    "\n",
    "This is the command we started with, but it should now produce our report. Open `nessie_report.txt` in VS Code and paste its **complete contents** below, leaving out the terminal's `Saved nessie_report.txt` message. Run the command a second time and check for yourself that the file still contains only one report.\n",
    "\n",
    "<div class=\"alert alert-info\">\n",
    "<b>ℹ️ Two kinds of <code>__init__</code></b>\n",
    "\n",
    "The file <code>nessie_watch/__init__.py</code> supplies code for the package when it is imported. The method <code>Sighting.__init__()</code> sets up an individual sighting when it is created. They share a name, but serve different purposes.\n",
    "\n",
    "The generated command imports the package and then calls <code>main()</code>, so we don't put an unguarded <code>main()</code> call at the bottom of this file. When our test files import a module from <code>nessie_watch</code>, Python also imports the package. We don't want each test to generate the report as a side effect!\n",
    "</div>"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-023",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 5.2.1\n",
    "PASTE FILE CONTENTS HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-024",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"11\" hidden></span>\n",
    "\n",
    "### 5.3 What have we actually established?\n",
    "\n",
    "Close the test files, run `uv run nessie-watch` once more, and open the saved report. This starts a fresh Python process, as the list and all its `Sighting` instances are created again from the CSV. The text report remains on disk after that process ends. We have made the **information** persist, rather than the original Python objects.\n",
    "\n",
    "There is still a rather large creature in the room, however. Our program has calculated a mean likelihood and selected a strongest report, but neither of those calculations checked whether the archivist's scores meant anything in the first place.\n",
    "\n",
    "Discuss with your partner: which parts of our work disappear when Python stops, and which files could another person use to recreate it?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-025",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 5.3.1\n",
    "WRITE NOTES HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-026",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"12\" hidden></span>\n",
    "\n",
    "## 6 Bonus Tasks\n",
    "\n",
    "Well done, that was the core part of the tutorial! Your tutor will lead a group discussion at the end. If there's still time, choose whichever task interests you most. You can do them in any order.\n",
    "\n",
    "### 6.1 A monster of a slightly different format\n",
    "\n",
    "Word has reached **Loch Morar**, whose resident monster is usually called **Morag**. Their field station would like to use your program too. Unfortunately, their administrator has chosen a slightly different file format.\n",
    "\n",
    "Download [morag_sightings.csv](morag_sightings.csv) into the top level of your project. Open the file and compare it to the Nessie archive:\n",
    "\n",
    "- Fields are separated by **semicolons** instead of commas.\n",
    "- Some field names have changed: `reference`, `observed_on`, `shore`, `observers`, and `account` take the places of `report_id`, `date`, `location`, `witnesses`, and `description`.\n",
    "- The `likelihood` field has a percent sign, such as `65%`. It is still the archivist's subjective score, on the same scale as before.\n",
    "\n",
    "In `archive.py`, write a separate `load_morag_sightings(path)` that creates the **same Sighting objects**. For this variation, copying the reader function and adapting its input details is fine. Use `csv.DictReader(file, delimiter=\";\")`, then look up the appropriate field names and remove `%` with `.replace()`, and finally convert the score to an integer. Keep the original loader working too.\n",
    "\n",
    "Create `check_morag.py` alongside your other test files. Without changing your `Sighting` class or the existing analysis functions, make it print these lines:\n",
    "\n",
    "```text\n",
    "Reports loaded: 8\n",
    "Follow-up visits: 5\n",
    "Strongest report: LM-005\n",
    "```\n",
    "\n",
    "Then print the IDs of the visits and a location summary, and check a description containing a semicolon to see whether it survived intact. What did you need to change, and which parts of the program could you reuse? Record your output and a few notes below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-027",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 6.1.1\n",
    "WRITE NOTES HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-028",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"12\" hidden></span>\n",
    "\n",
    "### 6.2 Speedboating\n",
    "\n",
    "Our location summary works, but it searches the entire archive again for every location. That is fine for eighteen reports. What if a future archivist imports a million of them, each with a slightly different location name?\n",
    "\n",
    "Improve `location_summary(sightings)` so that it counts reports using a **dictionary**:\n",
    "\n",
    "1. Start an empty dictionary whose keys will be location names and whose values will be counts.\n",
    "2. Visit each sighting once. If its location is not already a key, add it with an initial count of zero. Then increase that location's count.\n",
    "3. After counting, loop over the dictionary's keys to build the summary string. As dictionary iteration follows the order in which keys were added, you can preserve the original output order.\n",
    "\n",
    "Keep the old version under a different function name while you compare them. Both should return exactly the same string for the supplied archive and an empty string for an empty list. Try a small list of your own objects too, including several reports from the same place.\n",
    "\n",
    "Think about the amount of repeated work each algorithm does. In the old version, how many times is the full archive searched when there are six different locations? What happens if every report has a different location?\n",
    "\n",
    "<div class=\"alert alert-success\">\n",
    "<b>Challenge: overlapping reports</b>\n",
    "\n",
    "If you'd like a further challenge, use a string such as `sighting.date + \" | \" + sighting.location` as a counting key. Print combinations that occur more than once as **possible overlapping reports**. Do those groups establish that the accounts are duplicates, or would the station still need to investigate?\n",
    "</div>\n",
    "\n",
    "Record your checks and explanation below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-029",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 6.2.1\n",
    "WRITE NOTES HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-030",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"12\" hidden></span>\n",
    "\n",
    "### 6.3 A proper investigation\n",
    "\n",
    "Tired of programming? Here's something completely different to do.\n",
    "\n",
    "So far, we have treated Nessie as a data management problem. Researchers have tried a rather different approach: looking for **environmental DNA**, or eDNA, left by organisms in the water. In 2018, a team led by the University of Otago collected water samples from Loch Ness for this purpose.\n",
    "\n",
    "Start with [this BBC report on the project](https://www.bbc.com/news/uk-scotland-highlands-islands-49495145), then find at least one additional source that discusses what eDNA can and cannot detect. Use a research paper or a scientific institution's explanation for that second source.\n",
    "\n",
    "Investigate:\n",
    "\n",
    "- What did the researchers actually sample and measure, and what kinds of organisms did they report finding?\n",
    "- What would an absence of a particular DNA signal let us conclude?\n",
    "- How did headlines about the findings compare with the researchers' own claims?\n",
    "\n",
    "Finally, propose **three extra fields** for our archive that would help someone assess a sighting more carefully. Explain how the station could collect each one and what limitation would remain. For example, recording a photograph is useful, but a photograph can still be blurry, misidentified, or manipulated."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "permanence-031",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%check 6.3.1\n",
    "WRITE NOTES HERE"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "permanence-032",
   "metadata": {},
   "source": [
    "<span class=\"tutorial-section-marker\" data-tutorial-section=\"12\" hidden></span>\n",
    "\n",
    "## 7 Summary\n",
    "\n",
    "Well done, explorers! You've built a small Python package that reads an archive, creates objects from its records, applies a rule, and writes a report. The objects disappear when the interpreter stops, but the files allow the information to be used again. Our administrative grip on the monster has improved considerably.\n",
    "\n",
    "Some key terms to take away:\n",
    "\n",
    "<dl>\n",
    "    <dt><b>CSV</b></dt>\n",
    "    <dd>A text format for records with named fields, commonly separated by commas.</dd>\n",
    "    <dt><b>Package</b></dt>\n",
    "    <dd>A way to organise related Python modules. Our package lives in <code>src/nessie_watch</code>, with <code>__init__.py</code> supplying its initialisation code.</dd>\n",
    "    <dt><b>Separation of concerns</b></dt>\n",
    "    <dd>Giving different parts of a program clear responsibilities, so that changing how we read the archive need not change what a sighting means.</dd>\n",
    "</dl>\n",
    "\n",
    "### Sources and further reading\n",
    "\n",
    "- [Python: csv](https://docs.python.org/3/library/csv.html), particularly `DictReader` and the advice on opening files.\n",
    "- [uv: creating projects](https://docs.astral.sh/uv/concepts/projects/init/) and [project structure](https://docs.astral.sh/uv/concepts/projects/layout/).\n",
    "- [University of Otago: First phase of hunt for Loch Ness monster complete](https://web.archive.org/web/20190421091013/https://www.otago.ac.nz/news/news/releases/otago690003.html), an archived announcement from 28 June 2018 describing the sampling project.\n",
    "- [BBC: Loch Ness Monster may be a giant eel, say scientists](https://www.bbc.com/news/uk-scotland-highlands-islands-49495145), reporting on the environmental DNA study.\n",
    "\n",
    "---\n",
    "\n",
    "### Start over\n",
    "\n",
    "Use this button to erase every answer in this notebook and begin the tutorial again.\n",
    "\n",
    "<button type=\"button\" data-tutorial-reset style=\"padding:8px 14px;border:1px solid #b91c1c;border-radius:6px;background:#dc2626;color:white;font-weight:600;cursor:pointer\">Reset all tutorial progress</button>"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
