{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://edukors.org/graph/schema/v1/",
  "title": "Course",
  "description": "A course: its metadata (info), its nodes (prewritten content, AI-generated content and student activities) and the edges that define the order between them.",
  "type": "object",
  "required": [
    "info",
    "nodes",
    "edges"
  ],
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string",
      "format": "uri",
      "description": "Address of the version of this schema the course was written against. Optional, but writing it makes editors validate the file as it is typed, and records which version of the format the course follows.",
      "examples": [
        "https://edukors.org/graph/schema/v1/"
      ]
    },
    "info": {
      "$ref": "#/$defs/courseInfo",
      "description": "All course-level information."
    },
    "nodes": {
      "type": "array",
      "description": "The nodes of the course.",
      "minItems": 1,
      "items": {
        "$ref": "#/$defs/node"
      }
    },
    "edges": {
      "type": "array",
      "description": "Directed links between nodes. They define the sequence of the course. The edges leaving a node are evaluated from top to bottom, and the first one whose 'when' holds is the path taken, which is what makes the graph adaptive; an edge without 'when' always holds, so it works as the fallback and should come last. A condition on a key the student has not produced yet never holds, so an edge can safely test a node that may not have been answered. An edge may lead back to a node the student has already gone through, which is how a course sends a student to study again and retake an activity. Coming back to a node is a new visit: quiz, form and bool nodes are answered again, and choice, score and noul nodes are judged again, so that in either case the new answers overwrite the keys produced before, while dynamic-md and dynamic-html nodes keep showing the content already generated for the student. Every such cycle needs an edge out of it whose condition the student can eventually meet, or the student never leaves it. A choice, score or noul node decides nothing by itself: it only produces keys, and the edges leaving it are what read them. One of those edges must be unconditional, because a judgement that did not happen produces no key at all, and because a judgement that did happen can land anywhere in its range; without it a student can reach the node and have nowhere to go. That unconditional edge must not lead to a dynamic node written from this judgement, since there would be no judgement for it to write from.",
      "items": {
        "$ref": "#/$defs/edge"
      }
    }
  },
  "$defs": {
    "courseInfo": {
      "type": "object",
      "description": "Course-level information, grouped in a single object.",
      "required": [
        "course-id",
        "source-language",
        "other-languages",
        "title",
        "author",
        "version",
        "date",
        "start"
      ],
      "additionalProperties": false,
      "properties": {
        "course-id": {
          "type": "string",
          "format": "uuid",
          "description": "Unique identifier of the course (UUID)."
        },
        "source-language": {
          "$ref": "#/$defs/langCode",
          "description": "The language the course was originally written in."
        },
        "other-languages": {
          "type": "array",
          "description": "Languages the course has been translated into. Must not include the source language.",
          "items": {
            "$ref": "#/$defs/langCode"
          },
          "uniqueItems": true
        },
        "title": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Title of the course in each language. Plain text."
        },
        "description": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Short summary of the course in each language, for catalogues and listings. Plain text. Optional."
        },
        "author": {
          "type": "string",
          "minLength": 1,
          "description": "Author of the course: the person or institution responsible for the content.",
          "examples": [
            "John Doe",
            "Acme Corp"
          ]
        },
        "version": {
          "type": "string",
          "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
          "description": "Version of the course, as MAJOR.MINOR.PATCH.",
          "examples": [
            "1.0.0",
            "2.3.1"
          ]
        },
        "date": {
          "type": "string",
          "format": "date",
          "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
          "description": "Date of this version of the course, as YYYY-MM-DD.",
          "examples": [
            "2026-09-15"
          ]
        },
        "start": {
          "type": "string",
          "pattern": "^(sm|sh|dm|dh|q|f|b)[0-9]+$",
          "description": "id of the node the course starts at. It is the only entry point: every other node is reached by following edges from here. A choice, score or noul node cannot be the start, which is why their prefixes are not accepted here: they judge what the student has produced, and at the start there is nothing to judge.",
          "examples": [
            "sm1"
          ]
        },
        "sections": {
          "type": "array",
          "description": "Titles of the groups the nodes are displayed in, when they should be shown with a name rather than a bare number. Optional.",
          "items": {
            "$ref": "#/$defs/section"
          }
        },
        "system-prompt": {
          "type": "string",
          "minLength": 1,
          "description": "Course-wide instructions for the AI, sent as the system prompt of every call that generates content: the 'prompt' of dynamic-md and dynamic-html nodes, which is sent as the user message. Use it for tone, audience and rules that apply everywhere, so each node only carries what is specific to it. It is sent as written: {{STORAGE: key}} is not resolved here, only in the prompts of the nodes. It does not reach the choice, score and noul nodes, which take no system prompt: what they are given is their own 'state' and questions, and nothing else. It is recommended to write prompts in English and to instruct the AI to answer in the student's language. Optional.",
          "examples": [
            "You are tutoring first-year undergraduates. Be concise, use plain language, and always give a worked example."
          ]
        }
      }
    },
    "langCode": {
      "type": "string",
      "pattern": "^[a-z]{2}(-[A-Z]{2})?$",
      "description": "ISO 639-1 language code, with an optional region (e.g. pt, en, pt-BR).",
      "examples": [
        "pt",
        "ar",
        "de",
        "en",
        "es",
        "fr",
        "hi",
        "it",
        "ru",
        "zh"
      ]
    },
    "section": {
      "type": "object",
      "description": "The name of one group of nodes.",
      "required": [
        "number",
        "title"
      ],
      "additionalProperties": false,
      "properties": {
        "number": {
          "type": "integer",
          "minimum": 1,
          "description": "The number used in the 'section' of the nodes of this group."
        },
        "title": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Title of the group in each language. Plain text."
        }
      }
    },
    "localizedText": {
      "type": "object",
      "description": "A piece of text in one language.",
      "required": [
        "lang",
        "text"
      ],
      "additionalProperties": false,
      "properties": {
        "lang": {
          "$ref": "#/$defs/langCode"
        },
        "text": {
          "type": "string"
        }
      }
    },
    "localizedTextList": {
      "type": "array",
      "description": "The same text in several languages. At least the source-language version must be present. Each field that uses it says how its text is formatted: plain text, markdown for a block of content, or inline markdown for a short line such as a label. Wherever markdown is accepted, simple HTML (e.g. <img>) and LaTeX are accepted too. LaTeX is written between $...$ or \\(...\\) inside a line, and between $$...$$ or \\[...\\] as a block of its own. A single $ only opens a formula when it does not follow a letter or a digit and is followed by something other than a space, and only closes it when it follows something other than a space and is not followed by a digit, so amounts such as 'R$ 10' or 'US$5' stay as written. Text inside `code` is never read as LaTeX.",
      "minItems": 1,
      "items": {
        "$ref": "#/$defs/localizedText"
      }
    },
    "nodeType": {
      "type": "string",
      "description": "Type of the node. It determines which fields exist in 'content'.",
      "enum": [
        "static-md",
        "static-html",
        "dynamic-md",
        "dynamic-html",
        "quiz",
        "form",
        "bool",
        "choice",
        "score",
        "noul"
      ],
      "x-enumDescriptions": {
        "static-md": "Markdown content written in advance and shown to the student as written.",
        "static-html": "Web content built in advance and shown to the student as built.",
        "dynamic-md": "A prompt the AI runs during the course; the markdown it returns is shown to the student. With 'from' it is the node that writes the feedback of a judgement.",
        "dynamic-html": "A prompt the AI runs during the course; the web content it returns is shown to the student. With 'from' it is the node that writes the feedback of a judgement.",
        "quiz": "A set of objective questions the student answers.",
        "form": "A form the student fills in. With 'instructions' and a 'text-area' field it is also how a writing task is set, to be judged by a score node.",
        "bool": "A yes/no question the student answers; the answer is stored and is typically used to branch the course into a longer or a shorter path.",
        "choice": "A question the AI answers by picking one of the options the author listed, and never anything outside that list. The answer is stored and read by the edges leaving the node, which is how the course sends the student down one track rather than another.",
        "score": "A question the AI answers by placing the student on a scale of levels the author wrote, from the lowest to the highest. The level reached is stored and read by the edges leaving the node.",
        "noul": "A yes/no question the AI answers with the probability that the answer is yes, from 0 to 1. The probability is stored and read by the edges leaving the node, which compare it against a threshold the author chooses."
      }
    },
    "node": {
      "type": "object",
      "description": "A single node of the course. Most nodes are something the student sees. The choice, score and noul nodes are not: the student passes through them without stopping, while the AI judges what they have produced so far, the answer is stored, and the course carries on along the first edge whose condition that answer satisfies. A judgement that did not happen, because the call failed or because the player did not accept the answer, such as one less sure than the player requires, stores nothing at all, so no condition on its keys holds and the student takes the unconditional edge. Nothing in this format ever stores a made-up judgement: a grade and the feedback written from it are both worked out from the same answer, and an invented number would become a confident, false account of the student's own work.",
      "required": [
        "id",
        "type",
        "title",
        "content"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^(sm|sh|dm|dh|q|f|b|c|s|n)[0-9]+$",
          "description": "Identifier of the node, unique within the course. The prefix must match the type: sm (static-md), sh (static-html), dm (dynamic-md), dh (dynamic-html), q (quiz), f (form), b (bool), c (choice), s (score), n (noul). The nodes that only show content written in advance carry a two-letter prefix; every other node stores data under keys of its own.",
          "examples": [
            "sm1",
            "sh2",
            "dm3",
            "dh4",
            "q6",
            "f7",
            "b8",
            "c9",
            "s10",
            "n11"
          ]
        },
        "type": {
          "$ref": "#/$defs/nodeType"
        },
        "section": {
          "type": "integer",
          "minimum": 1,
          "description": "Number of the group the node is displayed in, named in 'info.sections' when it has a title. It is only a visual hint for the frontend, so that courses with many nodes can be shown in blocks; it has no effect on the order of the course, which is defined by 'edges'. Defaults to 1.",
          "default": 1
        },
        "position": {
          "$ref": "#/$defs/nodePosition"
        },
        "title": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Title of the node in each language. Plain text."
        },
        "content": {
          "type": "object",
          "description": "Content of the node. Its shape depends on 'type'."
        }
      },
      "allOf": [
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "static-md"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^sm[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/staticMdContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "static-html"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^sh[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/staticHtmlContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "dynamic-md"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^dm[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/dynamicMdContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "dynamic-html"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^dh[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/dynamicHtmlContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "quiz"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^q[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/quizContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "form"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^f[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/formContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "bool"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^b[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/boolContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "choice"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^c[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/choiceContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "score"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^s[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/scoreContent"
              }
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "const": "noul"
              }
            }
          },
          "then": {
            "properties": {
              "id": {
                "pattern": "^n[0-9]+$"
              },
              "content": {
                "$ref": "#/$defs/noulContent"
              }
            }
          }
        }
      ]
    },
    "nodePosition": {
      "type": "object",
      "description": "Where the node sits on the canvas of a builder that lets the author arrange the nodes by hand, so that the layout the author chose survives saving and reopening the course. It is only a visual hint for the builder: it has no effect on the order of the course, which is defined by 'edges', nor on what the student sees, and players ignore it. The units and the origin are those of the builder's own canvas. Without it, the builder lays the node out by itself. Optional.",
      "required": [
        "x",
        "y"
      ],
      "additionalProperties": false,
      "properties": {
        "x": {
          "type": "number",
          "description": "Horizontal coordinate of the node on the canvas."
        },
        "y": {
          "type": "number",
          "description": "Vertical coordinate of the node on the canvas."
        }
      },
      "examples": [
        {
          "x": 34,
          "y": -12
        }
      ]
    },
    "staticMdContent": {
      "type": "object",
      "description": "Markdown content written in advance, translated into every language of the course.",
      "required": [
        "item"
      ],
      "additionalProperties": false,
      "properties": {
        "item": {
          "$ref": "#/$defs/localizedTextList",
          "description": "The content, in markdown."
        }
      }
    },
    "staticHtmlContent": {
      "type": "object",
      "description": "Web content built in advance, translated into every language of the course.",
      "required": [
        "item"
      ],
      "additionalProperties": false,
      "properties": {
        "item": {
          "$ref": "#/$defs/localizedTextList",
          "description": "The HTML markup shown to the student. It runs in the student's browser, so the frontend renders it in a sandboxed iframe rather than injecting it into the page: scripts run, but they have no access to the page or to the session of the student, and links open in a new tab. The markup must be self-contained: CSS, SVG and scripts written inline, with no external scripts or stylesheets."
        }
      }
    },
    "dynamicMdContent": {
      "type": "object",
      "description": "A prompt the AI runs to generate markdown during the course. It usually exists in a single language, and the answer is generated in the student's language. The generated result must be saved in the record that tracks the student's progress, so that the node always shows the same content whenever the student comes back to it. It produces one key, '<id>.text', holding that generated content: a node with id 'dm1' therefore produces 'dm1.text'. That key is what lets a later judgement read a challenge written for this student, so that the AI judging the answer sees the same task the student saw. Because the content is generated once and kept, the key does not change when the student comes back.",
      "required": [
        "prompt"
      ],
      "additionalProperties": false,
      "properties": {
        "prompt": {
          "$ref": "#/$defs/localizedTextList",
          "description": "The prompt, in markdown. It is recommended to write prompts in English and to instruct the AI to answer in the student's language. It can reference saved data with {{STORAGE: key}}, using a key produced by a dynamic-md, dynamic-html, quiz, form, bool, choice, score or noul node, such as {{STORAGE: f1.goal}}. The spaces around the key are optional, but 'STORAGE' must be followed directly by the colon. Before the prompt is sent, a key the student has not produced yet is replaced by an empty text, and a list of answers (a 'check' field) by its values separated by ', '."
        },
        "from": {
          "$ref": "#/$defs/feedbackFrom"
        }
      }
    },
    "dynamicHtmlContent": {
      "type": "object",
      "description": "A prompt the AI runs to generate web content during the course. It usually exists in a single language, and the answer is generated in the student's language. The generated result must be saved in the record that tracks the student's progress, so that the node always shows the same content whenever the student comes back to it. It produces one key, '<id>.text', holding that generated markup, on the same terms as a dynamic-md node. Because this markup is written by the AI at run time and never reviewed by the author, the frontend must render it in a sandboxed iframe, without access to the session of the student.",
      "required": [
        "prompt"
      ],
      "additionalProperties": false,
      "properties": {
        "prompt": {
          "$ref": "#/$defs/localizedTextList",
          "description": "The prompt describing the HTML to build. It is recommended to write prompts in English and to instruct the AI to answer in the student's language. It can reference saved data with {{STORAGE: key}}, using a key produced by a dynamic-md, dynamic-html, quiz, form, bool, choice, score or noul node, such as {{STORAGE: f1.goal}}, resolved as described for the prompt of dynamic-md nodes. The HTML returned is rendered like the item of a static-html node."
        },
        "from": {
          "$ref": "#/$defs/feedbackFrom"
        }
      }
    },
    "feedbackFrom": {
      "type": "string",
      "pattern": "^(c|s|n)[0-9]+$",
      "description": "The id of the judgement node this content is written from. When set, the AI receives, after the prompt, the 'state' that node judged and the judgement itself, rendered in full: for each question its instructions and its answer -- on a score the level reached, with the text of every level of the scale and the weight the judgement put on each; on a choice the option picked; on a noul the probability of a yes -- then its points when there are any and its confidence, and the total of the node when its questions carry points. The prompt then only says how to write -- audience, length, tone, what to praise and what to correct -- and must not judge again: the level is settled, and a feedback that argues with it is the one thing this arrangement exists to prevent. The node is only reached when that judgement exists, so a course where the unconditional edge of the judge leads here is refused: there would be nothing to write from. {{STORAGE: key}} still works in the prompt for anything else. Optional.",
      "examples": [
        "s1"
      ]
    },
    "quizContent": {
      "type": "object",
      "description": "A set of objective questions the student answers. It produces three keys in the record that tracks the student's progress, where <id> is the id of the node: '<id>.score' (how many answers were right), '<id>.total' (how many questions there are) and '<id>.percent' (the share of right answers, from 0 to 100). A node with id 'q1' therefore produces 'q1.score', 'q1.total' and 'q1.percent'. A question that carries a 'key' produces one more key, holding the 'value' of the option the student picked. They can be used in the prompt of dynamic-md and dynamic-html nodes with {{STORAGE: key}} and in the 'when' of the edges.",
      "required": [
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "items": {
          "type": "array",
          "minItems": 1,
          "description": "The questions, in display order.",
          "items": {
            "$ref": "#/$defs/quizQuestion"
          }
        }
      }
    },
    "quizQuestion": {
      "type": "object",
      "description": "An objective question. Exactly one of its options is correct, so the student picks a single answer.",
      "required": [
        "question",
        "options"
      ],
      "additionalProperties": false,
      "properties": {
        "key": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]*$",
          "description": "Name of the question, unique within the quiz. It makes this single answer addressable: a question named 'fractions' in the node 'q1' produces the key 'q1.fractions', holding the 'value' of the option the student picked. It cannot be 'score', 'total' or 'percent', which the quiz already produces on its own. Omit it when only the overall score matters. Optional.",
          "examples": [
            "fractions",
            "q3"
          ],
          "not": {
            "enum": [
              "score",
              "total",
              "percent"
            ]
          }
        },
        "question": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text of the question in each language, in markdown."
        },
        "options": {
          "type": "array",
          "minItems": 2,
          "description": "Answer options, in display order. Exactly one of them must have 'correct' set to true. The frontend shows them in the order listed and never shuffles them, so the position of the correct option is the author's choice and should vary from question to question.",
          "items": {
            "$ref": "#/$defs/quizOption"
          },
          "contains": {
            "type": "object",
            "properties": {
              "correct": {
                "const": true
              }
            },
            "required": [
              "correct"
            ]
          },
          "minContains": 1,
          "maxContains": 1
        },
        "feedback": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Explanation shown after the student answers, in markdown."
        }
      }
    },
    "quizOption": {
      "type": "object",
      "description": "One answer option of a question.",
      "required": [
        "value",
        "label",
        "correct"
      ],
      "additionalProperties": false,
      "properties": {
        "value": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]*$",
          "description": "Identifier of the option, unique within the question. It is what gets stored when the student picks this option, and what the 'when' of an edge is compared to, so it must not change with the language of the labels.",
          "examples": [
            "a",
            "b",
            "one-half"
          ]
        },
        "label": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text of the option in each language, in inline markdown."
        },
        "correct": {
          "type": "boolean",
          "description": "Whether this is the correct answer. Exactly one option of the question has it set to true."
        }
      }
    },
    "formContent": {
      "type": "object",
      "description": "A form filled in by the student. Each field produces one key in the record that tracks the student's progress, named '<id>.<key of the field>', where <id> is the id of the node: a field named 'goal' in the node 'f1' produces 'f1.goal'. It holds the text the student typed, or the 'value' of the option picked; a 'check' field holds the list of the values picked. They can be used in the prompt of dynamic-md and dynamic-html nodes and in the 'state' of a judgement with {{STORAGE: key}}, and in the 'when' of the edges. A form with 'instructions' and a single 'text-area' field is how a writing task is set: the student writes, and a score node judges what they wrote.",
      "required": [
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "instructions": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text shown above the fields, in markdown. For a writing task this is the assignment: what to write, how long it should be, what will be judged and what happens next. The 'label' of a field is a single line, so this is where the paragraphs, the lists and the worked example go. Optional.",
          "examples": [
            [
              {
                "lang": "en",
                "text": "## The cat you would adopt\n\nPick one cat species or breed and argue for adopting it, in **150 to 250 words**. Describe its habitat, what it eats and one trait that justifies your choice.\n\nYou will get a comment on what you wrote before moving on."
              }
            ]
          ]
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "description": "Fields of the form, in display order.",
          "items": {
            "$ref": "#/$defs/formField"
          }
        }
      }
    },
    "formField": {
      "type": "object",
      "description": "A single form field.",
      "required": [
        "key",
        "type",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "key": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]*$",
          "description": "Name of the field, unique within the form. Together with the id of the node it forms the key of the answer in the record that tracks the student's progress: a field named 'goal' in the node 'f1' answers to 'f1.goal'.",
          "examples": [
            "goal",
            "prior-experience"
          ]
        },
        "type": {
          "type": "string",
          "enum": [
            "text-line",
            "text-area",
            "radio",
            "check",
            "select"
          ],
          "description": "Type of the field. It determines whether 'options' is used.",
          "x-enumDescriptions": {
            "text-line": "A single line of free text.",
            "text-area": "A multi-line block of free text.",
            "radio": "A list of options, of which the student picks one.",
            "check": "A list of options, of which the student may pick several.",
            "select": "A drop-down list, of which the student picks one."
          }
        },
        "label": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Question or label of the field in each language, in inline markdown."
        },
        "required": {
          "type": "boolean",
          "default": false,
          "description": "Whether the student must answer this field before moving on."
        },
        "options": {
          "type": "array",
          "minItems": 2,
          "description": "Choices offered by the field, in display order. Used by 'radio', 'check' and 'select'; not allowed on the text types.",
          "items": {
            "$ref": "#/$defs/formOption"
          }
        },
        "min-words": {
          "type": "integer",
          "minimum": 1,
          "description": "The fewest words the answer may have. The frontend counts the words as the student types and does not let them move on below it. Use it on a writing task to make the length in the instructions a rule rather than a request, and leave it out where a short answer is legitimate. Only on 'text-line' and 'text-area'. Optional."
        },
        "max-words": {
          "type": "integer",
          "minimum": 1,
          "description": "The most words the answer may have, counted and enforced like 'min-words'. When both are given, this one must be the larger, which the validator checks. Only on 'text-line' and 'text-area'. Optional."
        }
      },
      "allOf": [
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "enum": [
                  "radio",
                  "check",
                  "select"
                ]
              }
            }
          },
          "then": {
            "required": [
              "options"
            ],
            "not": {
              "anyOf": [
                {
                  "required": [
                    "min-words"
                  ]
                },
                {
                  "required": [
                    "max-words"
                  ]
                }
              ]
            }
          }
        },
        {
          "if": {
            "required": [
              "type"
            ],
            "properties": {
              "type": {
                "enum": [
                  "text-line",
                  "text-area"
                ]
              }
            }
          },
          "then": {
            "not": {
              "required": [
                "options"
              ]
            }
          }
        }
      ]
    },
    "formOption": {
      "type": "object",
      "description": "One choice offered by a form field.",
      "required": [
        "value",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "value": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9-]*$",
          "description": "Identifier of the option, unique within the field. It is what gets stored when the student picks this option, and what the 'when' of an edge is compared to, so it must not change with the language of the labels.",
          "examples": [
            "career",
            "hobby"
          ]
        },
        "label": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text of the option in each language, in inline markdown. A 'select' field shows it as plain text, since a drop-down list cannot hold formatting."
        }
      }
    },
    "boolContent": {
      "type": "object",
      "description": "A yes/no question the student answers. It produces one key in the record that tracks the student's progress, '<id>.answer', holding true when the student answered yes and false when the student answered no: a node with id 'b1' therefore produces 'b1.answer'. It can be used in the prompt of dynamic-md and dynamic-html nodes with {{STORAGE: key}} and in the 'when' of the edges, which is its main use: one edge for the yes path and one for the no path, so that the course continues into a longer or a shorter track.",
      "required": [
        "question"
      ],
      "additionalProperties": false,
      "properties": {
        "question": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text of the question in each language, in markdown."
        },
        "yes-label": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text of the affirmative answer in each language, when the default wording of the frontend should be replaced by something more specific, such as 'Yes, show me more'. Plain text. Optional."
        },
        "no-label": {
          "$ref": "#/$defs/localizedTextList",
          "description": "Text of the negative answer in each language, when the default wording of the frontend should be replaced by something more specific, such as 'No, move on'. Plain text. Optional."
        },
        "default": {
          "type": "boolean",
          "description": "Answer preselected when the node is shown. Omit it to leave both answers unselected, which forces the student to choose. Optional."
        }
      }
    },
    "judgeState": {
      "type": "object",
      "minProperties": 1,
      "description": "What the AI is given to judge, written by the author as named fields. It is never shown to the student, and so it has no version per language. Each field is a piece of the case: a text written here as it stands, or saved data embedded with {{STORAGE: key}}, using a key produced by a dynamic-md, dynamic-html, quiz, form, bool, choice, score or noul node, such as {{STORAGE: f1.text}}. The spaces around the key are optional, but 'STORAGE' must be followed directly by the colon. Before the call is made, a key the student has not produced yet is replaced by an empty text, and a list of answers (a 'check' field) by its values separated by ', '. Name the fields for what they hold, because the 'instructions' point at them by name, and give the AI the task as well as the answer: a judgement that sees only what the student wrote cannot tell whether they wrote what was asked. Write only what the judgement needs: what is named here is what the AI sees, and nothing else is sent.",
      "propertyNames": {
        "pattern": "^[a-z][a-z0-9-]*$"
      },
      "additionalProperties": {
        "type": [
          "string",
          "array"
        ],
        "items": {
          "type": "string"
        }
      },
      "examples": [
        {
          "task": "Argue for one cat species or breed you would adopt, in 150 to 250 words, covering habitat, diet and one trait that justifies the choice.",
          "answer": "{{STORAGE: f1.text}}"
        }
      ]
    },
    "judgeKey": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9-]*$",
      "description": "Name of the question, unique within the node. Together with the id of the node it forms the key of the answer in the record that tracks the student's progress: a question named 'track' in the node 'c1' answers to 'c1.track'. It cannot end in '-confidence' or '-points', which are the suffixes the node produces on its own, and it cannot be 'total' or 'percent', which a score node produces for the whole of it.",
      "examples": [
        "track",
        "evidence",
        "ready-for-the-next-part"
      ],
      "not": {
        "anyOf": [
          {
            "pattern": "-(confidence|points)$"
          },
          {
            "enum": [
              "total",
              "percent"
            ]
          }
        ]
      }
    },
    "judgeInstructions": {
      "type": "string",
      "minLength": 1,
      "description": "The question the AI answers, written as one clear and specific question about the 'state'. It is never shown to the student and has no version per language; it is recommended to write it in English. Name the field of the 'state' it judges, so that the AI knows which part of the case the question is about, and say what it should leave aside, because a question that does not exclude anything tends to weigh everything: 'Judge the evidence in the field answer. Do not judge grammar or spelling.' Ask one thing: a question that tries to weigh several things at once is better split into several questions of its own, combined afterwards in the 'when' of the edges with 'and' or 'or'. It can reference saved data with {{STORAGE: key}}, resolved as described for 'state'.",
      "examples": [
        "Which track does this student need next?",
        "Judge how well the field answer backs its claims with evidence. Do not judge grammar, length or tone."
      ]
    },
    "choiceContent": {
      "type": "object",
      "description": "One or more questions the AI answers by picking an option from a list the author wrote. Each question produces two keys in the record that tracks the student's progress, where <id> is the id of the node and <key> the name of the question: '<id>.<key>' (the name of the option picked) and '<id>.<key>-confidence' (how sure the AI is, from 0 to 1). A question named 'track' in the node 'c1' therefore produces 'c1.track' and 'c1.track-confidence'. They can be used in the prompt of dynamic-md and dynamic-html nodes with {{STORAGE: key}} and in the 'when' of the edges, which is their main use. The answer is always one of the options listed and never anything else, so edges covering every option cover every answer the AI can give; the unconditional edge is what catches a judgement that did not happen. How sure the AI must be for a judgement to count at all is for the player to decide, not the course; an edge whose branch needs more compares '<id>.<key>-confidence' in its 'when'. A judgement that did not happen stores nothing at all: no key of this node is written, and it never stores a middle value, a default or a guess.",
      "required": [
        "state",
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "state": {
          "$ref": "#/$defs/judgeState"
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "description": "The questions. They are judged together, in a single call, against the same 'state', and none of them sees the answer of another. Asking several narrow questions here costs almost nothing over asking one.",
          "items": {
            "$ref": "#/$defs/choiceQuestion"
          }
        }
      }
    },
    "choiceQuestion": {
      "type": "object",
      "description": "A question answered by picking one of the options listed in 'criteria'.",
      "required": [
        "key",
        "instructions",
        "criteria"
      ],
      "additionalProperties": false,
      "properties": {
        "key": {
          "$ref": "#/$defs/judgeKey"
        },
        "instructions": {
          "$ref": "#/$defs/judgeInstructions"
        },
        "criteria": {
          "type": "object",
          "description": "The options the answer is picked from: a map of the name of each option to what that option covers. The name is what gets stored and what the 'when' of an edge is compared to, so it must not change with the language of the course; the description may be null when the name says it all. Include an option such as 'unclear' when the state may not fit any of the others, because the AI has to answer with one of these and has nowhere else to put a case the list forgot.",
          "minProperties": 2,
          "maxProperties": 255,
          "propertyNames": {
            "pattern": "^[a-z][a-z0-9-]*$"
          },
          "additionalProperties": {
            "type": [
              "string",
              "null"
            ]
          },
          "examples": [
            {
              "remedial": "Confuses the basic concepts and needs them again",
              "standard": "Has the essentials and can carry on",
              "advanced": "Goes beyond what was taught",
              "unclear": "The text is too short or too off-topic to tell"
            }
          ]
        }
      }
    },
    "scoreContent": {
      "type": "object",
      "description": "One or more questions the AI answers by placing the student on a scale of levels the author wrote. Each question produces two keys in the record that tracks the student's progress, where <id> is the id of the node and <key> the name of the question: '<id>.<key>' (the level reached) and '<id>.<key>-confidence' (how sure the AI is, from 0 to 1). A question named 'evidence' in the node 's1' therefore produces 's1.evidence' and 's1.evidence-confidence'. A question that carries 'points' produces '<id>.<key>-points' as well, and the node then produces '<id>.total' and '<id>.percent' for the whole of it. These are what the 'when' of the edges compares and what a prompt embeds with {{STORAGE: key}}. The level runs from 0, the first level listed, to one less than the number of levels, and it is not a whole number: it is each level number weighted by the probability the AI gave it, so 1.43 on a scale of three levels is an ordinary answer meaning 'between the second and the third, nearer the second'. Compare it with gt, gte, lt and lte rather than with eq. It is not a percentage: '<id>.percent' of a quiz and of a score node with points run from 0 to 100, while this one runs over the levels of its own question. How sure the AI must be for a judgement to count at all is for the player to decide, not the course; an edge whose branch needs more compares '<id>.<key>-confidence' in its 'when'. A judgement that did not happen stores nothing at all: no key of this node is written, and it never stores a middle value, a default or a guess.",
      "required": [
        "state",
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "state": {
          "$ref": "#/$defs/judgeState"
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "description": "The questions. They are judged together, in a single call, against the same 'state', and none of them sees the answer of another. Judging several things separately, each with its own scale, and joining them in the 'when' of an edge with 'and' gives a course that is easier to adjust than one question that tries to weigh everything at once.",
          "items": {
            "$ref": "#/$defs/scoreQuestion"
          }
        }
      }
    },
    "scoreQuestion": {
      "type": "object",
      "description": "A question answered by placing the state on the scale listed in 'criteria'.",
      "required": [
        "key",
        "instructions",
        "criteria"
      ],
      "additionalProperties": false,
      "properties": {
        "key": {
          "$ref": "#/$defs/judgeKey"
        },
        "instructions": {
          "$ref": "#/$defs/judgeInstructions"
        },
        "criteria": {
          "type": "array",
          "minItems": 2,
          "maxItems": 10,
          "description": "The levels of the scale, in order, from the low end to the high end. The position is the number of the level: the first one listed is level 0. Say what each level means rather than naming it, so that the AI can tell one from the next, and keep the step between levels even, since the answer is an average over them.",
          "items": {
            "type": "string",
            "minLength": 1
          },
          "examples": [
            [
              "No evidence given for the claims",
              "Claims backed by one example",
              "Claims backed by several examples, weighed against each other"
            ]
          ]
        },
        "points": {
          "type": "array",
          "minItems": 2,
          "maxItems": 10,
          "description": "How many points each level is worth, in the same order as 'criteria' and with the same number of entries, which the validator checks. When it is there the question also produces '<id>.<key>-points': each level's points weighted by its probability, so a question worth 0, 100 and 200 points answered with 0.57 on level 1 and 0.43 on level 2 gives 143. The node then produces '<id>.total', the sum of the '-points' of every question that carries points, and '<id>.percent', that sum over the highest it could have been, from 0 to 100. This is what a grade is in this format: a number worked out from the judgement, never a second opinion asked of the AI. Omit it on a question that only decides where the student goes next. Optional.",
          "items": {
            "type": "number",
            "minimum": 0
          },
          "examples": [
            [
              0,
              40,
              80,
              120,
              160,
              200
            ]
          ]
        }
      }
    },
    "noulContent": {
      "type": "object",
      "description": "One or more yes/no questions the AI answers with the probability that the answer is yes. Each question produces one key in the record that tracks the student's progress, '<id>.<key>', holding a number from 0 to 1: a question named 'ready' in the node 'n1' therefore produces 'n1.ready'. It can be used in the prompt of dynamic-md and dynamic-html nodes with {{STORAGE: key}} and in the 'when' of the edges, which is its main use. Unlike choice and score it produces no '-confidence' key, because the probability is itself the measure of how sure the AI is: near 1 is yes, near 0 is no, and near 0.5 means it cannot tell. The edges compare it against a threshold the author picks: 0.5 when both answers are equally easy to act on, higher when acting on a wrong yes costs more, lower when missing a true yes costs more. A judgement that did not happen stores nothing at all: no key of this node is written, and it never stores a middle value, a default or a guess.",
      "required": [
        "state",
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "state": {
          "$ref": "#/$defs/judgeState"
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "description": "The questions. They are judged together, in a single call, against the same 'state', and none of them sees the answer of another.",
          "items": {
            "$ref": "#/$defs/noulQuestion"
          }
        }
      }
    },
    "noulQuestion": {
      "type": "object",
      "description": "A yes/no question, answered with the probability that the answer is yes.",
      "required": [
        "key",
        "instructions"
      ],
      "additionalProperties": false,
      "properties": {
        "key": {
          "$ref": "#/$defs/judgeKey"
        },
        "instructions": {
          "$ref": "#/$defs/judgeInstructions"
        },
        "criteria": {
          "type": "object",
          "description": "What a yes and a no cover, for a question that could be read in more than one way. Omit it when the question is plain on its own. Optional.",
          "additionalProperties": false,
          "properties": {
            "true": {
              "type": "string",
              "minLength": 1,
              "description": "What counts as a yes."
            },
            "false": {
              "type": "string",
              "minLength": 1,
              "description": "What counts as a no."
            }
          },
          "examples": [
            {
              "true": "The student names the mechanism, even loosely",
              "false": "The student only restates the result"
            }
          ]
        }
      }
    },
    "edge": {
      "type": "object",
      "description": "A directed link between two nodes. Without 'when' it is unconditional; with 'when' it is only taken when the condition holds.",
      "required": [
        "from",
        "to"
      ],
      "additionalProperties": false,
      "properties": {
        "from": {
          "type": "string",
          "description": "id of the source node."
        },
        "to": {
          "type": "string",
          "description": "id of the target node."
        },
        "when": {
          "$ref": "#/$defs/edgeCondition",
          "description": "Condition that makes this edge the one to follow: a single comparison, or several joined with 'and' or 'or', which can be nested. Omit it for an unconditional edge, which should be listed last among the edges leaving the same node, as a fallback."
        }
      }
    },
    "edgeCondition": {
      "description": "A condition tested against the record that tracks the student's progress: either a single comparison, or several of them joined with 'and' or 'or'.",
      "oneOf": [
        {
          "$ref": "#/$defs/comparison"
        },
        {
          "$ref": "#/$defs/andCondition"
        },
        {
          "$ref": "#/$defs/orCondition"
        }
      ]
    },
    "comparison": {
      "type": "object",
      "description": "A single comparison between a stored value and 'value'. It does not hold when the key has not been produced yet, which is the case while the student has not gone through the node that produces it.",
      "required": [
        "key",
        "operator",
        "value"
      ],
      "additionalProperties": false,
      "properties": {
        "key": {
          "type": "string",
          "minLength": 1,
          "description": "Key of the value to test, written as '<node-id>.<name>': one of the keys produced by a dynamic-md, dynamic-html, quiz, form, bool, choice, score or noul node. It is the same key used in {{STORAGE: key}}.",
          "pattern": "^(dm|dh|q|f|b|c|s|n)[0-9]+\\.[a-z][a-z0-9-]*$",
          "examples": [
            "q1.percent",
            "f1.goal",
            "b1.answer",
            "c1.track",
            "c1.track-confidence",
            "s1.evidence",
            "s1.percent",
            "n1.ready"
          ]
        },
        "operator": {
          "type": "string",
          "enum": [
            "eq",
            "ne",
            "gt",
            "gte",
            "lt",
            "lte",
            "contains",
            "not-contains"
          ],
          "description": "How the stored value is compared to 'value'.",
          "x-enumDescriptions": {
            "eq": "The stored value is equal to 'value'. For the answer of a bool node, compare it to true or false; for the answer of a choice node, to the name of one of the options that question lists.",
            "ne": "The stored value is different from 'value'.",
            "gt": "The stored value is greater than 'value'. For numbers.",
            "gte": "The stored value is greater than or equal to 'value'. For numbers.",
            "lt": "The stored value is less than 'value'. For numbers.",
            "lte": "The stored value is less than or equal to 'value'. For numbers.",
            "contains": "The stored value contains 'value'. For texts and for lists of answers, such as a 'check' field.",
            "not-contains": "The stored value does not contain 'value'. For texts and for lists of answers, such as a 'check' field."
          }
        },
        "value": {
          "type": [
            "string",
            "number",
            "boolean"
          ],
          "description": "Value the stored one is compared to."
        }
      }
    },
    "andCondition": {
      "type": "object",
      "description": "Holds when every condition listed in 'and' holds.",
      "required": [
        "and"
      ],
      "additionalProperties": false,
      "properties": {
        "and": {
          "type": "array",
          "minItems": 2,
          "description": "Conditions that must all hold. They can themselves be comparisons or further 'and' / 'or' groups.",
          "items": {
            "$ref": "#/$defs/edgeCondition"
          }
        }
      }
    },
    "orCondition": {
      "type": "object",
      "description": "Holds when at least one of the conditions listed in 'or' holds.",
      "required": [
        "or"
      ],
      "additionalProperties": false,
      "properties": {
        "or": {
          "type": "array",
          "minItems": 2,
          "description": "Conditions of which at least one must hold. They can themselves be comparisons or further 'and' / 'or' groups.",
          "items": {
            "$ref": "#/$defs/edgeCondition"
          }
        }
      }
    }
  }
}
