From cada557224c5dde26cba2ec51a49fd80ef2ac252 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 21:21:59 +0100 Subject: [PATCH 1/4] implement: Document the draft workflow from source add to build (t45) --- docs/source/drafts.md | 407 ++++++++++++++++++ docs/source/index.md | 1 + tests/fixtures/drafts/README.md | 6 + .../fixtures/drafts/walkthrough/commands.json | 93 ++++ .../fixtures/drafts/walkthrough/expected.json | 117 +++++ tests/fixtures/drafts/walkthrough/source.md | 16 + tests/fixtures/drafts/walkthrough/spec.yaml | 5 + tests/test_drafts_docs.py | 105 +++++ 8 files changed, 750 insertions(+) create mode 100644 docs/source/drafts.md create mode 100644 tests/fixtures/drafts/walkthrough/commands.json create mode 100644 tests/fixtures/drafts/walkthrough/expected.json create mode 100644 tests/fixtures/drafts/walkthrough/source.md create mode 100644 tests/fixtures/drafts/walkthrough/spec.yaml create mode 100644 tests/test_drafts_docs.py diff --git a/docs/source/drafts.md b/docs/source/drafts.md new file mode 100644 index 0000000..5cf5383 --- /dev/null +++ b/docs/source/drafts.md @@ -0,0 +1,407 @@ +# 📝 Drafts + +A draft is a JSON file beside a problem sheet. It holds the markdown the sheet was converted to, +the blocks that markdown is made of, one field per question, part and solution, and the log of +the commands that wrote those fields. `in2lambda convert` reads a document and writes a set in +one command. A draft is the other way of working: in2lambda converts the document once, and the +questions are then written, checked and rebuilt from the frozen markdown and the log. + +This page walks one sheet through every step of that: freeze the document, fill the draft in from +a spec or by commands, check the draft over, and write the set. The sheet is `sheet.md`: + +```markdown +# Pipe flow problems + +Answer both questions, and show your working. + +Q1. Water flows through a horizontal pipe of diameter $d$ at speed $v$. + +(a) Find the volume flow rate. (4 marks) + +Q2. A submarine is towed at speed $U$ through still water. + +(a) Find the drag force on it. + +## Solutions + +The flow rate is $Q = \pi d^2 v / 4$. +The drag is $F = \half \rho U^2 A C_d$. +``` + +The first command below converts a `.docx` or a `.tex` sheet to markdown beside it, and every +command after that reads the markdown. + +## Freeze the document + +```bash +$ in2lambda source add sheet.md +Wrote /home/you/sheet/sheet.draft.json +``` + +`in2lambda source add` converts the document to markdown, hashes the markdown, and writes the +draft beside it. The draft is named after the document, so a folder of sheets holds one draft per +sheet: + +```bash +$ cat sheet.draft.json +{ + "fields": {}, + "log": [], + "sources": [ + { + "blocks": [ + { + "end": 1, + "id": "b1", + "start": 1, + "type": "heading" + }, + { + "end": 3, + "id": "b2", + "start": 3, + "type": "paragraph" + }, + { + "end": 5, + "id": "b3", + "start": 5, + "type": "paragraph" + }, + { + "end": 7, + "id": "b4", + "start": 7, + "type": "list item" + }, + { + "end": 9, + "id": "b5", + "start": 9, + "type": "paragraph" + }, + { + "end": 11, + "id": "b6", + "start": 11, + "type": "list item" + }, + { + "end": 13, + "id": "b7", + "start": 13, + "type": "heading" + }, + { + "end": 16, + "id": "b8", + "start": 15, + "type": "paragraph" + } + ], + "hash": "sha256:dd51e0597b0e2e091651223a1acebc6f3c694cea9abf61eba4faebace24782e5", + "source": "sheet.md" + } + ] +} +``` + +A block is one top-level element pandoc found - a heading, a paragraph, a list item - with the +lines it spans and an id to quote it by. `fields` holds the questions, parts and solutions +written from the sheet, and `log` holds the commands that wrote them. Both are empty until a spec +or a command fills them in. + +A line range identifies text only while that text does not change, which is what the hash +records. Every command that reads the draft hashes the markdown on disk and compares it with the +hash the draft holds, and refuses where the two differ, because line 5 of an edited document is +not the line the earlier commands were run against. `in2lambda source add --start-over` freezes +the document again and discards the draft written from it. + +A draft records every command in its log as the command runs. `in2lambda draft replay` builds the +draft again from the frozen markdown and the log alone, so a draft a model wrote is rebuilt and +checked with no model in the loop. + +## Read the source back + +```bash +$ in2lambda source show +b1 1 # Pipe flow problems + 2 +b2 3 Answer both questions, and show your working. + 4 +b3 5 Q1. Water flows through a horizontal pipe of diameter $d$ at speed $v$. + 6 +b4 7 (a) Find the volume flow rate. (4 marks) + 8 +b5 9 Q2. A submarine is towed at speed $U$ through still water. + 10 +b6 11 (a) Find the drag force on it. + 12 +b7 13 ## Solutions + 14 +b8 15 The flow rate is $Q = \pi d^2 v / 4$. + 16 The drag is $F = \half \rho U^2 A C_d$. +``` + +`in2lambda source show` prints the frozen markdown numbered, with the id of each block against +the line it starts on. The commands below name those ids and those line numbers: `b3` is a block, +`s5` is one line of the source, and `s15:16` is a range of it. + +## Fill the draft in from a spec + +A spec is a YAML file of selectors saying which blocks are questions, which are parts and which +are solutions. This sheet labels each question `Q1.`, `Q2.`, and the `question` selector matches +that label: + +```yaml +question: Para text~'^Q\d+\.' +part: ListItem +strip: ['^Q\d+\. '] +ignore: Header +layout: PartsSepSol +``` + +```bash +$ in2lambda spec run spec.yaml +b2 (lines 3-3) is in no field and not marked ignore. +b8 (lines 15-16) is in no field and not marked ignore. +``` + +The spec writes the two questions, their parts and the two headings it was told to ignore. It +then reports the blocks it made nothing of: the rubric, which no selector matches, and the +solutions, which this spec has no `solution` selector for. A spec run accounts for every block of +the document or names the blocks it left out. + +Each field records where its value came from: + +```json +{ + "q1.text": { + "by": "you", + "edited": false, + "layer": 1, + "ranges": [[5, 5]], + "value": "Water flows through a horizontal pipe of diameter $d$ at speed $v$." + } +} +``` + +`layer` says what wrote the field: 1 a spec, 2 a predicate the spec called, 3 a range of the +source quoted by a command, 4 a literal somebody typed. `ranges` are the lines of the frozen +markdown the value was copied from, and `by` is who ran the command. The value holds no `Q1. `, +because `strip` takes that label off the front of every value a selector matched. See +[specs](spec.md) for the selectors, the predicates and the layouts a spec is written from. + +## Fill the draft in by commands + +Where no spec fits the document, the commands write the same fields one at a time. A spec fills a +draft in once, so the draft starts again before they run: + +```bash +$ in2lambda source add --start-over sheet.md +Wrote /home/you/sheet/sheet.draft.json +``` + +The title is nothing to take a question from, and block `b3` is the first question: + +```bash +$ in2lambda draft mark ignore b1 +Wrote b1.ignore. +$ in2lambda draft question add --text b3 +Wrote q1.text. +``` + +Each command prints the key of the field it wrote, which is the key the next command names. +`question add` takes the first number no question has taken, and quotes the block into it: + +```json +{ + "q1.text": { + "by": "you", + "edited": false, + "layer": 3, + "ranges": [[5, 5]], + "value": "Q1. Water flows through a horizontal pipe of diameter $d$ at speed $v$." + } +} +``` + +A command quotes the lines as the source writes them, so this `q1.text` holds the `Q1. ` the +sheet numbers the question with. The spec above took that label off with `strip`. in2lambda +records the command in the log as it applies it: + +```json +{ + "args": { + "text": "b3" + }, + "by": "you", + "command": "question add" +} +``` + +`by` is your username, or what `--by` gives it. That name is how a field a model wrote is told +from one a person wrote. + +Line 7 gives the marks the first part is worth, which the field should not hold. So the part is +typed out rather than quoted, and `b4` is marked ignore, because no field accounts for it now: + +```bash +$ in2lambda draft mark ignore b4 +Wrote b4.ignore. +$ in2lambda draft part add q1 --literal 'Find the volume flow rate.' +Wrote q1.p1.text. +``` + +```json +{ + "q1.p1.text": { + "by": "you", + "edited": true, + "layer": 4, + "ranges": [], + "value": "Find the volume flow rate." + } +} +``` + +A literal is layer 4: no line of the source says it, so the field has no range behind it and +arrives edited. + +The second question and its part are both quoted: + +```bash +$ in2lambda draft question add --text b5 +Wrote q2.text. +$ in2lambda draft part add q2 --text b6 +Wrote q2.p1.text. +``` + +Pandoc reads `(a)` as a list marker, so `b6` is a list item, and a field quoted from a list item +is dedented by the item's own marker. `q2.p1.text` holds `Find the drag force on it.` + +## Check what is left + +```bash +$ in2lambda validate +b2 (lines 3-3) is in no field and not marked ignore. +Warning: q2.p1 (lines 11-11) has no solution: neither q2.p1.solution nor q2.solution is written. +b7 (lines 13-13) is in no field and not marked ignore. +b8 (lines 15-16) is in no field and not marked ignore. +Warning: q1.p1 has no solution: neither q1.p1.solution nor q1.solution is written. +``` + +`in2lambda validate` checks the draft over and writes what it finds into the draft as the draft's +report. Whoever is writing the draft reads that report to find what is left to do. A finding at +level error is the draft contradicting its own source: lines nothing accounts for, two fields +quoted from the same lines, a numbering with a hole in it, maths Lambda Feedback will not render. +A finding at level warning may be right about the sheet, and a question nothing answers is one of +those: many sheets write their solutions in another file, and some sheets have none. + +The findings are printed in the order of the source, and the findings about no particular line +last. `q1.p1` was typed out, so it names no lines. + +## Account for the rest + +The rubric and the Solutions heading are two of the blocks the report names, and neither holds a +question: + +```bash +$ in2lambda draft mark ignore b2 +Wrote b2.ignore. +$ in2lambda draft mark ignore b7 +Wrote b7.ignore. +``` + +The sheet writes its two solutions on two lines with no blank line between them, so pandoc made +one paragraph of both and `b8` spans lines 15 to 16. `split block` cuts a block in two at the +line the second half starts on: + +```bash +$ in2lambda draft split block b8 16 +Wrote b8a and b8b. +``` + +The document is unchanged. `b8a` and `b8b` are blocks of the draft, quoted by their ids as any +other block is, and a replay arrives at the same two ids by running the same command again: + +```bash +$ in2lambda draft question solution q1 --text b8a +Wrote q1.solution. +$ in2lambda draft question solution q2 --text b8b +Wrote q2.solution. +``` + +## Change what a field says + +The sheet writes the second solution with `\half`, which KaTeX does not define, and +`in2lambda validate` reports that against `q2.solution`. No line of the source says the maths +correctly, so the wording itself is changed: + +```bash +$ in2lambda draft field replace q2.solution '\half' '\tfrac12' +Wrote q2.solution. +``` + +```json +{ + "q2.solution": { + "by": "you", + "edited": true, + "layer": 3, + "ranges": [[16, 16]], + "value": "The drag is $F = \\tfrac12 \\rho U^2 A C_d$." + } +} +``` + +The layer and the ranges are left as they were, so the field still names the lines it was quoted +from, and `edited` says the field no longer holds what those lines say. The wording replaced has +to occur in the field exactly once, and `--regex` reads it as a regular expression. + +## Replay and check again + +```bash +$ in2lambda draft replay +Replays as it stands. +``` + +`in2lambda draft replay` builds the draft again from the frozen markdown and the log, and +compares the result with the file byte for byte. It refuses where the document has changed since +in2lambda froze it, where the log names a command this version of in2lambda has not got, and +where somebody edited a field by hand. + +A command that changes the draft deletes the draft's report, because the report described the +draft before the command ran. So the checks run again: + +```bash +$ in2lambda validate +Nothing to report. +``` + +## Build and render + +```bash +$ in2lambda build +Wrote /home/you/sheet/out/set.zip +``` + +`in2lambda build` writes the draft out as a Lambda Feedback set: `out/set.zip` and the files it +is zipped from. It refuses a draft `in2lambda validate` has not been run on since the draft last +changed, and a draft whose report holds a finding at level error. It writes the set over a +finding at level warning, and prints that finding beside what it wrote. The fields become the +questions and parts of the export - see [the question format](question-format) for what the +export holds. + +```bash +$ in2lambda render +Wrote /home/you/sheet/out/question_000_Question_1.pdf +Wrote /home/you/sheet/out/question_001_Question_2.pdf +``` + +`in2lambda render` writes one PDF per question, compiled the way Lambda Feedback's PDF generator +compiles it, which needs pandoc and [xelatex](https://tug.org/texlive/). It does not read the +report: a draft is rendered to look at, including a draft there is something to fix in. + +`out/set.zip` is imported as [the quickstart](quickstart) describes, under "Import into Lambda +Feedback". diff --git a/docs/source/index.md b/docs/source/index.md index 816852c..26d9bbb 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -61,6 +61,7 @@ A fully type-annotated extensively documented Python library is available for th 🔎 Overview quickstart +drafts question-format filters/index spec diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md index ab491b2..7aa85c1 100644 --- a/tests/fixtures/drafts/README.md +++ b/tests/fixtures/drafts/README.md @@ -52,6 +52,12 @@ field is quoted from source 2 while the questions are quoted from source 1 - lin each, which is why the fields say which source they came from. The note at the end of the solutions is what the spec makes nothing of, and `mark ignore 2/b7` is a block of the second source named as one. +`walkthrough` is the sheet the [draft workflow page](../../../docs/source/drafts.md) walks +through, and holds every command that page shows: the questions keep the `Q1.` the sheet numbers +them with, the first question's part is typed out because the source writes the marks it is worth +beside it, the two solutions are one block that `split block` cuts in two, and one `field replace` +writes a command KaTeX defines over one it does not. The `spec.yaml` beside it is the spec that +page runs before starting the draft again, and no command here runs it. `degrees` writes `^\circ` into the maths of both a question and the one worked solution answering its two parts, and is the one folder whose report comes from `in2lambda.validation` over the set the draft describes rather than from the checks over the draft itself: the solution is reported diff --git a/tests/fixtures/drafts/walkthrough/commands.json b/tests/fixtures/drafts/walkthrough/commands.json new file mode 100644 index 0000000..c3bd97a --- /dev/null +++ b/tests/fixtures/drafts/walkthrough/commands.json @@ -0,0 +1,93 @@ +[ + { + "args": { + "block": "b1" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "text": "b3" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "block": "b4" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "literal": "Find the volume flow rate.", + "question": "q1" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "text": "b5" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "question": "q2", + "text": "b6" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "block": "b2" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "block": "b7" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "at": 16, + "block": "b8" + }, + "by": "tests", + "command": "split block" + }, + { + "args": { + "question": "q1", + "text": "b8a" + }, + "by": "tests", + "command": "question solution" + }, + { + "args": { + "question": "q2", + "text": "b8b" + }, + "by": "tests", + "command": "question solution" + }, + { + "args": { + "field": "q2.solution", + "new": "\\tfrac12", + "old": "\\half" + }, + "by": "tests", + "command": "field replace" + } +] diff --git a/tests/fixtures/drafts/walkthrough/expected.json b/tests/fixtures/drafts/walkthrough/expected.json new file mode 100644 index 0000000..def253d --- /dev/null +++ b/tests/fixtures/drafts/walkthrough/expected.json @@ -0,0 +1,117 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "b2.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": true + }, + "b4.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 7, + 7 + ] + ], + "value": true + }, + "b7.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 13, + 13 + ] + ], + "value": true + }, + "q1.p1.text": { + "by": "tests", + "edited": true, + "layer": 4, + "ranges": [], + "value": "Find the volume flow rate." + }, + "q1.solution": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 15, + 15 + ] + ], + "value": "The flow rate is $Q = \\pi d^2 v / 4$." + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 5, + 5 + ] + ], + "value": "Q1. Water flows through a horizontal pipe of diameter $d$ at speed $v$." + }, + "q2.p1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 11, + 11 + ] + ], + "value": "Find the drag force on it." + }, + "q2.solution": { + "by": "tests", + "edited": true, + "layer": 3, + "ranges": [ + [ + 16, + 16 + ] + ], + "value": "The drag is $F = \\tfrac12 \\rho U^2 A C_d$." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 9, + 9 + ] + ], + "value": "Q2. A submarine is towed at speed $U$ through still water." + } +} diff --git a/tests/fixtures/drafts/walkthrough/source.md b/tests/fixtures/drafts/walkthrough/source.md new file mode 100644 index 0000000..0f92abd --- /dev/null +++ b/tests/fixtures/drafts/walkthrough/source.md @@ -0,0 +1,16 @@ +# Pipe flow problems + +Answer both questions, and show your working. + +Q1. Water flows through a horizontal pipe of diameter $d$ at speed $v$. + +(a) Find the volume flow rate. (4 marks) + +Q2. A submarine is towed at speed $U$ through still water. + +(a) Find the drag force on it. + +## Solutions + +The flow rate is $Q = \pi d^2 v / 4$. +The drag is $F = \half \rho U^2 A C_d$. diff --git a/tests/fixtures/drafts/walkthrough/spec.yaml b/tests/fixtures/drafts/walkthrough/spec.yaml new file mode 100644 index 0000000..4768b0e --- /dev/null +++ b/tests/fixtures/drafts/walkthrough/spec.yaml @@ -0,0 +1,5 @@ +question: Para text~'^Q\d+\.' +part: ListItem +strip: ['^Q\d+\. '] +ignore: Header +layout: PartsSepSol diff --git a/tests/test_drafts_docs.py b/tests/test_drafts_docs.py new file mode 100644 index 0000000..e748c81 --- /dev/null +++ b/tests/test_drafts_docs.py @@ -0,0 +1,105 @@ +"""The draft workflow page is run as it is printed, against the fixture it is written from. + +``docs/source/drafts.md`` walks one sheet from `in2lambda source add` to `in2lambda build`, +printing every command and everything it wrote. Running the page here means a command +that says something else fails the test suite, rather than a page that goes on describing +a version of in2lambda that has gone. +""" + +import json +import re +import shlex +import shutil +from pathlib import Path +from typing import Any + +from click.testing import CliRunner +from conftest import DRAFTS_DIR, needs_compiler + +from in2lambda.main import cli + +PAGE = Path(__file__).parents[1] / "docs" / "source" / "drafts.md" +"""The page, which is read rather than generated: it is prose with commands in it.""" + +WALKTHROUGH = DRAFTS_DIR / "walkthrough" +"""The sheet the page walks through, which `tests/test_draft.py` covers as a draft.""" + +DIRECTORY = "/home/you/sheet" +"""What the page prints in place of the folder the commands were run in.""" + +_BLOCK = re.compile(r"^```(\w+)\n(.*?)^```", re.MULTILINE | re.DOTALL) +"""One fenced block of the page, as the language it is tagged with and its content.""" + + +def _runs(block: str) -> list[tuple[str, str]]: + """Each command of a console block, with everything printed under it.""" + runs: list[tuple[str, list[str]]] = [] + for line in block.splitlines(): + if line.startswith("$ "): + runs.append((line.removeprefix("$ "), [])) + else: + runs[-1][1].append(line) + return [(command, "\n".join(printed)) for command, printed in runs] + + +def _draft(tmp_path: Path) -> dict[str, Any]: + """The draft as the commands run so far have left it.""" + return json.loads((tmp_path / "sheet.draft.json").read_text()) + + +def _commands(block: str, tmp_path: Path) -> None: + """Runs a console block, checking what each command prints against the page.""" + for command, printed in _runs(block): + typed = shlex.split(command) + if typed[0] == "cat": + assert (tmp_path / typed[1]).read_text().rstrip("\n") == printed + continue + result = CliRunner().invoke(cli, typed[1:]) + assert result.exit_code == 0, result.output + # The folder the page names, since a command prints the path it wrote to and + # the test runs in a directory of pytest's own naming. + said = result.output.replace(str(tmp_path.resolve()), DIRECTORY) + assert said.rstrip("\n") == printed, command + + +def _quoted(block: str, tmp_path: Path) -> None: + """Checks a piece of the draft the page quotes against the draft itself. + + Either one field, named as the draft names it, or one entry of the log. + """ + shown = json.loads(block) + draft = _draft(tmp_path) + if "command" in shown: + assert shown in draft["log"] + else: + (key,) = shown + assert {key: draft["fields"][key]} == shown + + +@needs_compiler +def test_every_command_the_page_prints_says_what_the_page_says( + tmp_path: Path, monkeypatch +) -> None: + """A reader runs the page from the top, and this runs it the same way.""" + shutil.copy(WALKTHROUGH / "source.md", tmp_path / "sheet.md") + shutil.copy(WALKTHROUGH / "spec.yaml", tmp_path / "spec.yaml") + monkeypatch.chdir(tmp_path) + # Who the page records every command as having been run by, which is the default a + # command takes from the environment rather than anything the page passes. + monkeypatch.setenv("USER", "you") + monkeypatch.setenv("LOGNAME", "you") + + for language, block in _BLOCK.findall(PAGE.read_text()): + if language == "bash": + _commands(block, tmp_path) + elif language == "json": + _quoted(block, tmp_path) + elif language == "markdown": + assert block == (WALKTHROUGH / "source.md").read_text() + elif language == "yaml": + assert block == (WALKTHROUGH / "spec.yaml").read_text() + + # And the commands the page ran are the fixture's, so that what a reader is shown is + # what the draft tests rebuild, export and render. + log = [dict(entry, by="tests") for entry in _draft(tmp_path)["log"]] + assert log == json.loads((WALKTHROUGH / "commands.json").read_text()) From 0bfa75011105200d020b402582605b4e0d4f1b43 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 21:33:34 +0100 Subject: [PATCH 2/4] implement: Document the draft workflow from source add to build (t45) --- docs/source/drafts.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/source/drafts.md b/docs/source/drafts.md index 5cf5383..f15b741 100644 --- a/docs/source/drafts.md +++ b/docs/source/drafts.md @@ -1,8 +1,8 @@ # 📝 Drafts -A draft is a JSON file beside a problem sheet. It holds the markdown the sheet was converted to, -the blocks that markdown is made of, one field per question, part and solution, and the log of -the commands that wrote those fields. `in2lambda convert` reads a document and writes a set in +A draft is a JSON file beside a problem sheet. It names the markdown file the sheet was converted +to and holds that file's hash, the blocks the markdown is made of, one field per question, part +and solution, and the log of the commands that wrote those fields. `in2lambda convert` reads a document and writes a set in one command. A draft is the other way of working: in2lambda converts the document once, and the questions are then written, checked and rebuilt from the frozen markdown and the log. @@ -38,8 +38,7 @@ $ in2lambda source add sheet.md Wrote /home/you/sheet/sheet.draft.json ``` -`in2lambda source add` converts the document to markdown, hashes the markdown, and writes the -draft beside it. The draft is named after the document, so a folder of sheets holds one draft per +`in2lambda source add` hashes the markdown and writes the draft beside it. The draft is named after the document, so a folder of sheets holds one draft per sheet: ```bash @@ -118,8 +117,8 @@ not the line the earlier commands were run against. `in2lambda source add --star the document again and discards the draft written from it. A draft records every command in its log as the command runs. `in2lambda draft replay` builds the -draft again from the frozen markdown and the log alone, so a draft a model wrote is rebuilt and -checked with no model in the loop. +draft again from the frozen markdown and the log, reading nothing else, so a draft a model wrote +is rebuilt and checked without running the model again. ## Read the source back @@ -280,7 +279,7 @@ Wrote q2.p1.text. Pandoc reads `(a)` as a list marker, so `b6` is a list item, and a field quoted from a list item is dedented by the item's own marker. `q2.p1.text` holds `Find the drag force on it.` -## Check what is left +## Find the blocks in no field ```bash $ in2lambda validate @@ -292,16 +291,17 @@ Warning: q1.p1 has no solution: neither q1.p1.solution nor q1.solution is writte ``` `in2lambda validate` checks the draft over and writes what it finds into the draft as the draft's -report. Whoever is writing the draft reads that report to find what is left to do. A finding at -level error is the draft contradicting its own source: lines nothing accounts for, two fields -quoted from the same lines, a numbering with a hole in it, maths Lambda Feedback will not render. -A finding at level warning may be right about the sheet, and a question nothing answers is one of -those: many sheets write their solutions in another file, and some sheets have none. +report. Whoever is writing the draft reads that report to find the blocks in no field and the +questions with no solution. A finding at level error is the draft contradicting its own source: +lines nothing accounts for, two fields quoted from the same lines, a numbering with a hole in it, +maths Lambda Feedback will not render. A finding at level warning may be right about the sheet. +A question with no solution is reported at level warning, because many sheets write their +solutions in another file, and some sheets have none. The findings are printed in the order of the source, and the findings about no particular line last. `q1.p1` was typed out, so it names no lines. -## Account for the rest +## Mark the rubric and the Solutions heading ignored The rubric and the Solutions heading are two of the blocks the report names, and neither holds a question: @@ -335,8 +335,8 @@ Wrote q2.solution. ## Change what a field says The sheet writes the second solution with `\half`, which KaTeX does not define, and -`in2lambda validate` reports that against `q2.solution`. No line of the source says the maths -correctly, so the wording itself is changed: +`in2lambda validate` reports that against `q2.solution`. No line of the source writes the maths in +a command KaTeX defines, so the wording itself is changed: ```bash $ in2lambda draft field replace q2.solution '\half' '\tfrac12' From 45357aebb43187c90f4a2c1c02ac8b3d4be2c049 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 21:58:38 +0100 Subject: [PATCH 3/4] implement: Document the draft workflow from source add to build (t45) --- docs/source/drafts.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/source/drafts.md b/docs/source/drafts.md index f15b741..290fe54 100644 --- a/docs/source/drafts.md +++ b/docs/source/drafts.md @@ -110,10 +110,10 @@ lines it spans and an id to quote it by. `fields` holds the questions, parts and written from the sheet, and `log` holds the commands that wrote them. Both are empty until a spec or a command fills them in. -A line range identifies text only while that text does not change, which is what the hash -records. Every command that reads the draft hashes the markdown on disk and compares it with the -hash the draft holds, and refuses where the two differ, because line 5 of an edited document is -not the line the earlier commands were run against. `in2lambda source add --start-over` freezes +A line range requires that the text it refers to does not change. `in2lambda source add` records +the hash of the markdown in the draft. Every command that reads the draft hashes the markdown on +disk and compares it with the hash the draft holds, and refuses where the two differ, because +line 5 of an edited document is not the line the earlier commands were run against. `in2lambda source add --start-over` freezes the document again and discards the draft written from it. A draft records every command in its log as the command runs. `in2lambda draft replay` builds the @@ -143,8 +143,8 @@ b8 15 The flow rate is $Q = \pi d^2 v / 4$. ``` `in2lambda source show` prints the frozen markdown numbered, with the id of each block against -the line it starts on. The commands below name those ids and those line numbers: `b3` is a block, -`s5` is one line of the source, and `s15:16` is a range of it. +the line it starts on. A command names a block by its id, `b3`, a line of the source, `s5`, or a +range of lines, `s15:16`. ## Fill the draft in from a spec @@ -201,7 +201,8 @@ $ in2lambda source add --start-over sheet.md Wrote /home/you/sheet/sheet.draft.json ``` -The title is nothing to take a question from, and block `b3` is the first question: +The title holds no question, so `in2lambda draft mark ignore` marks the title. Block `b3` is the +first question: ```bash $ in2lambda draft mark ignore b1 @@ -239,8 +240,8 @@ records the command in the log as it applies it: } ``` -`by` is your username, or what `--by` gives it. That name is how a field a model wrote is told -from one a person wrote. +`by` is your username, or what `--by` gives it. A reader reads `by` to see whether a model or a +person wrote the field. Line 7 gives the marks the first part is worth, which the field should not hold. So the part is typed out rather than quoted, and `b4` is marked ignore, because no field accounts for it now: @@ -298,8 +299,8 @@ maths Lambda Feedback will not render. A finding at level warning may be right a A question with no solution is reported at level warning, because many sheets write their solutions in another file, and some sheets have none. -The findings are printed in the order of the source, and the findings about no particular line -last. `q1.p1` was typed out, so it names no lines. +`in2lambda validate` prints the findings in the order of the source, and prints the findings +about no particular line last. `q1.p1` was typed out, so it names no lines. ## Mark the rubric and the Solutions heading ignored @@ -400,8 +401,9 @@ Wrote /home/you/sheet/out/question_001_Question_2.pdf ``` `in2lambda render` writes one PDF per question, compiled the way Lambda Feedback's PDF generator -compiles it, which needs pandoc and [xelatex](https://tug.org/texlive/). It does not read the -report: a draft is rendered to look at, including a draft there is something to fix in. +compiles it, which needs pandoc and [xelatex](https://tug.org/texlive/). `in2lambda render` +writes the PDFs whatever the draft's report holds. A reader reads the PDFs to check a draft +before fixing it. `out/set.zip` is imported as [the quickstart](quickstart) describes, under "Import into Lambda Feedback". From 8d20db82fa5e48ea8e034b1b1c94c085d4f4caad Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 22:17:07 +0100 Subject: [PATCH 4/4] implement: Document the draft workflow from source add to build (t45) --- docs/source/drafts.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/source/drafts.md b/docs/source/drafts.md index 290fe54..d1b0fe6 100644 --- a/docs/source/drafts.md +++ b/docs/source/drafts.md @@ -1,7 +1,7 @@ # 📝 Drafts -A draft is a JSON file beside a problem sheet. It names the markdown file the sheet was converted -to and holds that file's hash, the blocks the markdown is made of, one field per question, part +A draft is a JSON file beside a problem sheet. A draft names the markdown file the sheet was +converted to and holds that file's hash, the blocks the markdown is made of, one field per question, part and solution, and the log of the commands that wrote those fields. `in2lambda convert` reads a document and writes a set in one command. A draft is the other way of working: in2lambda converts the document once, and the questions are then written, checked and rebuilt from the frozen markdown and the log. @@ -166,9 +166,9 @@ b2 (lines 3-3) is in no field and not marked ignore. b8 (lines 15-16) is in no field and not marked ignore. ``` -The spec writes the two questions, their parts and the two headings it was told to ignore. It -then reports the blocks it made nothing of: the rubric, which no selector matches, and the -solutions, which this spec has no `solution` selector for. A spec run accounts for every block of +The spec writes the two questions, their parts and the two headings it was told to ignore. +`in2lambda spec run` then reports the two blocks the spec wrote no field from: the rubric, which +no selector matches, and the solutions, which this spec has no `solution` selector for. A spec run accounts for every block of the document or names the blocks it left out. Each field records where its value came from: @@ -265,8 +265,8 @@ Wrote q1.p1.text. } ``` -A literal is layer 4: no line of the source says it, so the field has no range behind it and -arrives edited. +A literal is layer 4. No line of the source holds the wording, so the field's `ranges` is empty. +The field's `edited` is `true`. The second question and its part are both quoted: @@ -295,7 +295,7 @@ Warning: q1.p1 has no solution: neither q1.p1.solution nor q1.solution is writte report. Whoever is writing the draft reads that report to find the blocks in no field and the questions with no solution. A finding at level error is the draft contradicting its own source: lines nothing accounts for, two fields quoted from the same lines, a numbering with a hole in it, -maths Lambda Feedback will not render. A finding at level warning may be right about the sheet. +maths Lambda Feedback will not render. A question with no solution is reported at level warning, because many sheets write their solutions in another file, and some sheets have none. @@ -324,7 +324,8 @@ Wrote b8a and b8b. ``` The document is unchanged. `b8a` and `b8b` are blocks of the draft, quoted by their ids as any -other block is, and a replay arrives at the same two ids by running the same command again: +other block is. `in2lambda draft replay` runs `split block b8 16` again from the log, and writes +the blocks `b8a` and `b8b` again. The two solutions are then quoted into the two questions: ```bash $ in2lambda draft question solution q1 --text b8a @@ -368,9 +369,9 @@ Replays as it stands. ``` `in2lambda draft replay` builds the draft again from the frozen markdown and the log, and -compares the result with the file byte for byte. It refuses where the document has changed since -in2lambda froze it, where the log names a command this version of in2lambda has not got, and -where somebody edited a field by hand. +compares the result with the file byte for byte. `in2lambda draft replay` refuses where the +document has changed since in2lambda froze it, where the log names a command this version of +in2lambda has not got, and where somebody edited a field by hand. A command that changes the draft deletes the draft's report, because the report described the draft before the command ran. So the checks run again: @@ -388,9 +389,10 @@ Wrote /home/you/sheet/out/set.zip ``` `in2lambda build` writes the draft out as a Lambda Feedback set: `out/set.zip` and the files it -is zipped from. It refuses a draft `in2lambda validate` has not been run on since the draft last -changed, and a draft whose report holds a finding at level error. It writes the set over a -finding at level warning, and prints that finding beside what it wrote. The fields become the +is zipped from. `in2lambda build` refuses a draft that `in2lambda validate` has not been run on +since the draft last changed, and refuses a draft whose report holds a finding at level error. +Where the report holds only findings at level warning, `in2lambda build` prints each finding as a +`Warning:` line and then writes `out/set.zip`. The fields become the questions and parts of the export - see [the question format](question-format) for what the export holds.