diff --git a/docs/thesis/assets/comparison/README.md b/docs/thesis/assets/comparison/README.md
new file mode 100644
index 00000000..7b081254
--- /dev/null
+++ b/docs/thesis/assets/comparison/README.md
@@ -0,0 +1,20 @@
+# Comparison screenshots
+
+Author-provided captures used in the thesis's related-systems discussion.
+These are exact pixel crops; no interface text or results were redrawn.
+The originals remain in the author's local `random shit/` folder and are
+not needed to build the thesis.
+
+| Asset | Original | Crop (x, y, width, height), pixels |
+| --- | --- | --- |
+| `n8n-merge-append.png` | `a9fb75.png` | 475, 100, 1125, 630 |
+| `zapier-path-condition.png` | `filter.png` | 974, 111, 657, 762 |
+
+Coordinates use the top-left corner of the 1644 × 985 originals. The n8n
+crop retains configuration and output. The Zapier crop retains the condition
+and test result while excluding the account header and unrelated graph nodes.
+
+The screenshots illustrate configuration and sample results. Execution
+ordering and synchronization claims use the documentation cited in the
+manuscript. The Edit Fields capture (`fca1d3.png`) was omitted because its
+field-assignment example adds a separate topic to this comparison.
diff --git a/docs/thesis/assets/comparison/n8n-merge-append.png b/docs/thesis/assets/comparison/n8n-merge-append.png
new file mode 100644
index 00000000..ff1f9578
Binary files /dev/null and b/docs/thesis/assets/comparison/n8n-merge-append.png differ
diff --git a/docs/thesis/assets/comparison/zapier-path-condition.png b/docs/thesis/assets/comparison/zapier-path-condition.png
new file mode 100644
index 00000000..e8b3bd27
Binary files /dev/null and b/docs/thesis/assets/comparison/zapier-path-condition.png differ
diff --git a/docs/thesis/header-includes.tex b/docs/thesis/header-includes.tex
index d9610cdd..9ce676f0 100644
--- a/docs/thesis/header-includes.tex
+++ b/docs/thesis/header-includes.tex
@@ -6,13 +6,16 @@
\usepackage[dvipsnames]{xcolor}
\usepackage{fancyhdr}
\usepackage{float}
+% Let screenshots share a page with their explanation before using float pages.
+\floatplacement{figure}{htbp}
+\renewcommand{\topfraction}{0.85}
+\renewcommand{\textfraction}{0.1}
+\renewcommand{\floatpagefraction}{0.75}
\pagestyle{fancy}
-\usepackage{seqsplit}
-
-% Pandoc emits inline code as \texttt{...}. This blunt wrapper keeps long
-% paths and commands from overflowing PDF table cells.
-\let\origtexttt\texttt
-\renewcommand{\texttt}[1]{{\origtexttt{\seqsplit{#1}}}}
+% Keep inline identifiers intact. Move unusually long paths to a code block
+% or shorten their prose label instead of splitting callable names mid-word.
+% Libertinus Mono permits ordinary word hyphenation, so disable that too.
+\DeclareTextFontCommand{\texttt}{\ttfamily\hyphenchar\font=-1\relax}
\usepackage{fvextra}
\fvset{breaklines=true, breaknonspaceingroup=true, breakanywhere=true}
diff --git a/docs/thesis/system-design-implementation.md b/docs/thesis/system-design-implementation.md
index 68f44b87..60867c8b 100644
--- a/docs/thesis/system-design-implementation.md
+++ b/docs/thesis/system-design-implementation.md
@@ -67,10 +67,9 @@ diagram:
Preparing reports, transforming documents, and collecting workspace information
often involve procedures that must be repeated with new inputs. An AI assistant
can help perform such work. Repeating it requires preserving the operations
-and their data connections as an executable procedure. This thesis presents
-`lda.chat`, a
-programmable workflow platform for defining, checking, running, and inspecting
-reusable workspace procedures.
+and their data connections as an executable procedure. This thesis develops
+the programmable workflow subsystem of `lda.chat` for defining, checking,
+running, and inspecting reusable workspace procedures.
A workflow describes the operations to perform, the data they exchange, and
the decisions that select the next step. An **artifact** is an immutable saved
@@ -88,10 +87,10 @@ choice to separate a node's data output from its routing outcome.
The implementation includes a workflow runtime, Python authoring objects, a
workflow service, and adapters for trusted Python functions and external tools.
-Evidence includes
-a deterministic case study and focused tests of validation,
-execution, and persistence. Authoring usability remains to be evaluated as
-the interaction design develops.
+The evaluation focuses on workflow construction and execution through the
+Python client. It uses a deterministic case study and identifies focused
+tests of validation, execution, and persistence. Agent-driven authoring and
+authoring usability remain to be evaluated as the interaction design develops.
# Introduction
@@ -327,6 +326,13 @@ or produce combinations. The node's configuration and worked examples make
these operations distinguishable. Append waits for connected inputs and emits
their items in input order [@n8n-merge-2026].
+[@fig:n8n-merge-append] shows the Append setting and the resulting two
+output records. The fields remain in separate records; selecting Append
+does not combine them into one record.
+
+
+{#fig:n8n-merge-append width=100%}
+
Selecting a merge mode changes which records appear in the output.
For the report task, joining actions by owner is different from appending two
action lists.
@@ -356,6 +362,14 @@ distinguish complete records from records needing attention. Multiple paths
can qualify, so exclusivity must follow from the rules rather than the
branching appearance alone [@zapier-paths-2026].
+[@fig:zapier-path-condition] shows a rule testing whether the selected
+`Hello` field contains `world`. The test record matches, and the interface
+reports that this path would have continued. This preview lets the author
+check a rule against a concrete value before running the workflow.
+
+
+{#fig:zapier-path-condition width=75%}
+
The same documentation describes sequential execution of qualifying paths.
The rules can permit several branches to execute in one run. An exclusive
choice requires conditions that cannot qualify together.
@@ -367,6 +381,10 @@ The interface therefore needs to communicate both the condition being tested
and the consequence of a match. In lda.chat, an ordinary outcome selects
one successor, whereas several Zapier Paths may qualify.
+```{=latex}
+\begin{minipage}{\linewidth}
+```
+
## LangGraph: describing decisions in Python
LangGraph's Graph API lets an author define state, add node functions and
@@ -374,6 +392,47 @@ routing, compile the graph, and invoke it. For the report task, a developer can
represent extracted fields in state and write a routing function that selects
what happens next [@langgraph-graph-api-2026].
+In this small example, `clean` returns an update to the `text` field.
+The separate `choose` function reads that updated state and selects either
+the rendering step or the end of the graph:
+
+
+```python
+from typing import TypedDict
+from langgraph.graph import START, END, StateGraph
+
+class ReportState(TypedDict):
+ text: str
+ report: str
+
+def clean(state: ReportState):
+ return {"text": state["text"].strip()}
+
+def choose(state: ReportState):
+ return "render" if state["text"] else END
+
+def render(state: ReportState):
+ return {"report": "# Report\n\n" + state["text"]}
+
+builder = StateGraph(ReportState)
+builder.add_node("clean", clean)
+builder.add_node("render", render)
+builder.add_edge(START, "clean")
+builder.add_conditional_edges("clean", choose, ["render", END])
+builder.add_edge("render", END)
+graph = builder.compile()
+```
+
+```{=latex}
+\end{minipage}
+```
+
+Calling `graph.invoke({"text": " Notes ", "report": ""})` cleans the text,
+then produces a report. Whitespace-only input ends after `clean`. The state
+update and the routing decision are expressed in separate functions here;
+LangGraph also offers `Command` to return both from a node
+[@langgraph-graph-api-2026].
+
This authoring style exposes more behavior as code. Nodes produce state
updates, reducers determine how updates combine, and conditional routes select
subsequent execution. The documented model supports graph loops and super-step
@@ -417,8 +476,8 @@ mapping selects a configured provider that must satisfy the saved source
requirements. An app connection or credential is therefore only a partial
analogy: the source also supplies operations and their contracts.
-The design contribution is the composition of these established lifecycle
-responsibilities with the typed graph and client interface. Each system must
+The system combines these lifecycle responsibilities with its typed graph
+and Python client. Each system must
distinguish an edit to future work from the recorded
definition and data of a past execution.
@@ -485,7 +544,7 @@ flowchart LR
Read["Read
notes"] -->|ok| Extract["Extract
report"]
Extract -->|ok| Check{Complete?}
Check -->|ready| Render["Render
report"]
- Check -->|needs_information| Ask["Request
information"]
+ Check -->|needs_information| Ask["Request information
(interrupt)"]
Ask -->|submitted| Render
Render -->|ok| Finish([End])
```
@@ -611,7 +670,7 @@ classDiagram
Deployment "1" <-- "0..*" Run : started from
```
-## Available operations and environment binding
+## Available operations and deployment binding
A **capability** describes functionality available from a source. The report
example uses operation capabilities, such as extraction and rendering. A
@@ -622,7 +681,7 @@ A **source** groups capabilities under a configured identity. The extraction
operation might come from trusted Python code, while another operation or
document is supplied by an external service.
-An **environment binding**, also called a **deployment binding**, connects a
+A **deployment binding** connects a
logical source requirement in the workflow
to a concrete source in the environment. Validation checks whether that source
exists and matches the saved requirements. **Source drift** means those
@@ -767,6 +826,15 @@ Refreshing it requests a new snapshot. This makes network
activity explicit, although applications must decide when to refresh and how
to present progress.
+A local/static server can also start deployment runs on a schedule. The
+optional scheduler supports one-time and recurring starts and records each
+scheduled occurrence for inspection. The Python client's `App.create_schedule()`
+registers a schedule for an existing deployment; `App.schedule()` retrieves
+its configuration, and `App.schedule_occurrences()` retrieves its history.
+The server must have scheduling enabled to start runs without a connected
+client. The Limitations chapter discusses scheduling's storage and
+concurrency constraints.
+
The draft API and CLI also support persisted editing workspaces, which retain
unfinished definitions between requests. Python authoring can save directly
from its editable object. Both routes produce a saved definition for deployment
@@ -775,7 +843,8 @@ and execution.
## Executing the graph
The execution core is the runtime that runs the saved graph. It holds the
-workflow input, working state, and current execution positions. To execute
+workflow input, working state, and the active node for each in-flight item
+or call. To execute
an ordinary operation node, it resolves the node's input bindings, calls the
selected operation, checks its result, applies output bindings, and follows
the edge selected by the outcome. Repeating that cycle advances the run until
@@ -818,8 +887,8 @@ flowchart TB
A declared outcome such as `needs_information` is a workflow decision. A
handler exception or exhausted node-execution allowance is an execution
-failure. A workflow can therefore complete with a non-success business outcome
-without being a failed runtime execution. Where iteration supports collecting item
+failure. A workflow can complete with an outcome such as `needs_information`
+without failing the run. Where iteration supports collecting item
errors, that policy must be explicit; errors do not automatically become
ordinary outcome edges.
@@ -841,7 +910,8 @@ a time. An output binding appends each rendered report to a `reports`
state field, which the assembly step reads.
[@fig:foreach-graph] shows the authored graph. The `loop` edge enters the
-item body. Its return edge, `render.ok -> each`, ends that item's work.
+item body. Its return edge (a back-edge), `render.ok -> each`, ends that
+item's work.
The `done` edge leads to the continuation after the collection is processed.
@@ -862,7 +932,9 @@ controller from `Prepare documents` starts an invocation over the collection.
[@fig:foreach-region] follows one invocation from start to finish. Read
downward for time. Solid arrows request work or apply a data binding;
-dashed arrows report completion. The labels identify the data and routes.
+dashed arrows report completion. Here line style distinguishes requests from
+completion; in [@fig:report-state], it distinguishes execution from data
+bindings. The labels identify the data and routes.
```{.mermaid #fig:foreach-region width=95% caption="Serial iteration finishes both documents before assembly."}
@@ -888,7 +960,7 @@ The same authored render node executes twice. The runtime distinguishes
those executions so that finishing A advances to B, while finishing B permits
assembly. Each item returns to the foreach invocation that started it.
-The back-edge in [@fig:foreach-graph] records the item's return. Its output
+The return edge in [@fig:foreach-graph] records the item's return. Its output
binding appends the report before that return. A node in the body belongs to
that loop's control region. The validator rejects using the same node both
inside and outside the body, or returning past the immediate enclosing loop.
@@ -915,7 +987,7 @@ The parent item then continues to its own return edge.
sequenceDiagram
participant Item as Parent item A
participant Child as Child workflow
- participant Result as Item A reports writes
+ participant Result as A's pending report writes
participant Each as Foreach
Item->>Child: Input binding: document = A.md
activate Child
@@ -941,7 +1013,7 @@ its child does not permit assembly while B is still running. The foreach
waits for its required item completions before following `done`.
Concurrent items retain separate pending writes until their results combine.
-In [@fig:scope-boundaries], “Item A reports writes” represents these pending
+In [@fig:scope-boundaries], “A's pending report writes” represents these pending
writes. In serial mode, the output binding updates enclosing state so the next
item can read it.
@@ -1005,9 +1077,9 @@ re-inspects the exact artifact version. The resulting artifact object is
therefore reconstructed from the stored definition. Identity checks reject
responses identifying a different artifact or deployment.
-The service's `inspect_run` operation retrieves a stored run by its identifier.
-The Python client's `Run.refresh()` method calls that operation, verifies the
-run and deployment identities, and returns a new run snapshot. `Run.resume()`
+The Python client's `Run.refresh()` method retrieves a stored run by its
+identifier through the service's `inspect_run` operation. It verifies the
+run and deployment identities and returns a new run snapshot. `Run.resume()`
submits the requested response for an interrupted run and also returns a new
snapshot. The caller retains the returned object to see the updated status
and result.
@@ -1059,7 +1131,8 @@ document's separate view until the outer foreach combines the documents.
Publishing every serial section's write directly into shared workflow state
would expose unfinished document results to the other concurrent items.
-The runtime records where each execution belongs. A **frame** records one
+The runtime tracks the active node, the workflow invocation that owns its
+data, and the updates held separately by concurrent items. A **frame** records one
execution position, including its current node and owning foreach when it is
an iteration item. A **scope** contains the input, working state, and context
of one workflow invocation. A child workflow has its own scope. A **lineage**
@@ -1095,8 +1168,14 @@ unnamed current-item value would leave it unclear which one the node receives.
**Execution context** supplies runtime information to expressions and input
bindings. Its foreach entries are keyed by the authored controller's identity.
-Inside the inner body, `context.foreach.documents.item` selects the enclosing
-document, while `context.foreach.actions.item` selects the current action.
+Inside the inner body, two paths distinguish the enclosing document from
+the current action:
+
+```text
+context.foreach.documents.item # enclosing document
+context.foreach.actions.item # current action
+```
+
The runtime constructs these entries from the active frames. Repeated
executions of the same node therefore resolve the paths to their own items.
@@ -1198,9 +1277,10 @@ Their Pydantic models describe the input and output
contracts. The author consumes those operations from the service inventory;
the client does not import their implementations to execute them locally.
-The following blocks form one asynchronous Python session. The retained test
-executes these blocks against the real service in process, replacing only
-the HTTP connection assignment. It does not verify remote server startup.
+The Python blocks form one asynchronous session. The retained test runs that
+session against the real service in process. It substitutes an in-process
+connection for the HTTP connection assignment; remote server startup is not
+tested.
For HTTP use, the blocks assume a
server using `examples/report_workflow/wf.config.json`, reachable at its
configured address, and a fresh artifact name or unused version. The fixture
@@ -1444,9 +1524,9 @@ sequenceDiagram
Client->>API: Bind deployment to version
API-->>Client: Deployment and readiness
Client->>API: Run with input
- API->>Provider: Invoke graph steps
- Provider-->>API: Outputs and outcomes
- API-->>Client: Stopped run snapshot
+ API->>Provider: Invoke bound operation
+ Provider-->>API: Output and outcome
+ API-->>Client: Run snapshot
Client->>API: Inspect run and bounded trace
API-->>Client: Stored execution evidence
Client-->>Author: Result or diagnostic
@@ -1506,6 +1586,42 @@ recorded risks and followups, and a Markdown report headed
example's data reached the intended outputs. The extraction contract requires
the sectioned notes format described at the start of this chapter.
+## Registering a scheduled run
+
+The same deployment can be invoked later by the server's scheduler. With
+scheduling enabled on the local/static server, the client can register a
+one-time start and inspect its stored configuration and occurrence history:
+
+```python
+from datetime import UTC, datetime, timedelta
+
+schedule = await app.create_schedule(
+ schedule_id="report-once",
+ deployment_id=deployment.deployment_id,
+ trigger={
+ "kind": "oneshot",
+ "at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+ },
+ input_bindings=[{
+ "target": "text",
+ "expression": {"kind": "literal", "value": notes},
+ }],
+ max_steps=100,
+)
+schedule = await app.schedule(schedule.schedule_id)
+history = await app.schedule_occurrences(schedule.schedule_id, limit=10)
+```
+
+The input binding stores the current contents of `notes`. The scheduled run
+receives that text even if the original file changes. A recurring workflow
+that needs fresh notes would need an operation that retrieves them when it
+runs. Occurrence history records what happened to scheduled starts, including
+admission and completion. Immediately after registration, it can be empty.
+
+The session check verifies registration and inspection without waiting for
+the scheduled time. Scheduler execution is exercised separately in
+[`test_scheduled_deployment_example.py`](../../tests/examples/test_scheduled_deployment_example.py).
+
## What this case demonstrates
The executable check in
@@ -1520,7 +1636,7 @@ discovery and invocation, and the artifact/deployment/run lifecycle using
the raw-plan fixture. Those tests are evidence for the report operations
and lifecycle, complementing the Python session check.
-The Python presentation makes the current authoring experience concrete:
+The Python session shows the current authoring steps:
inspect operations, declare contracts, connect data and outcomes, save,
select bindings, and inspect an execution. The CLI and draft surface offer
alternative interfaces to related lifecycle operations.
@@ -1540,8 +1656,9 @@ are needed to evaluate the interaction beyond this scripted session.
Evaluation distinguishes three questions: whether the runtime follows its
contract, whether the public lifecycle composes correctly, and whether an
-author can use that lifecycle effectively. The current evidence addresses
-the first two through controlled tests and an adapted in-process walkthrough.
+author can use that lifecycle effectively. The reported in-process walkthrough
+exercises the public lifecycle. Targeted tests for runtime contracts are
+mapped in the Evidence Index.
The authoring assessment identifies the operations available through the
interface. Usability remains a separate evaluation question.
@@ -1845,7 +1962,7 @@ different from promising that an earlier action can be undone.
This report examined how a useful procedure can become a reusable workflow
that an author can define and an operator can inspect. The implemented
-system separates the saved definition, its environment bindings, and each
+system separates the saved definition, its deployment bindings, and each
execution into artifacts, deployments, and runs. Those distinctions give
workflow use a record beyond the lifetime of an editing session or a single
script invocation.
diff --git a/tests/docs/test_thesis_front_matter.py b/tests/docs/test_thesis_front_matter.py
index 1fed0026..7c2e4083 100644
--- a/tests/docs/test_thesis_front_matter.py
+++ b/tests/docs/test_thesis_front_matter.py
@@ -58,3 +58,46 @@ def test_abbreviations_compile_without_a_dummy_float(tmp_path: Path) -> None:
assert "API" in rendered
assert "Application Programming Interface" in rendered
assert "None 1" not in rendered
+
+
+@pytest.mark.skipif(
+ any(shutil.which(tool) is None for tool in ("pandoc", "xelatex", "pdftotext")),
+ reason="Pandoc, XeLaTeX, and pdftotext are needed for the inline-code build",
+)
+def test_inline_identifier_moves_to_next_line_intact(tmp_path: Path) -> None:
+ """A prose line ending must not split a callable's name into fragments."""
+ output = tmp_path / "inline.pdf"
+ identifier = "local.report.render_markdown_report"
+ result = subprocess.run(
+ [
+ "pandoc",
+ "--standalone",
+ "--pdf-engine=xelatex",
+ "--variable=geometry:textwidth=10cm",
+ "--variable=monofont:Libertinus Mono",
+ "--include-in-header",
+ str(THESIS / "header-includes.tex"),
+ "--output",
+ str(output),
+ ],
+ # Shift the identifier along the line to expose both forced splitting
+ # and font-dependent automatic hyphenation near the right margin.
+ input="\n\n".join(
+ f"{'word ' * count}`{identifier}`." for count in range(1, 16)
+ ),
+ text=True,
+ encoding="utf-8",
+ capture_output=True,
+ check=False,
+ timeout=90,
+ )
+ assert result.returncode == 0, result.stderr
+ rendered = subprocess.run(
+ ["pdftotext", "-layout", str(output), "-"],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ check=True,
+ timeout=15,
+ ).stdout
+ assert rendered.count(identifier) == 15
diff --git a/tests/docs/test_thesis_langgraph_example.py b/tests/docs/test_thesis_langgraph_example.py
new file mode 100644
index 00000000..7d6e70b2
--- /dev/null
+++ b/tests/docs/test_thesis_langgraph_example.py
@@ -0,0 +1,28 @@
+"""Exercise the comparison snippet when the optional LangGraph library exists."""
+
+from pathlib import Path
+
+import pytest
+
+
+@pytest.mark.parametrize(
+ ("text", "expected_report"),
+ [(" Notes ", "# Report\n\nNotes"), (" ", "")],
+)
+def test_langgraph_comparison_routes_updated_state(
+ text: str, expected_report: str
+) -> None:
+ """Check the rendered example without making LangGraph a product dependency."""
+ pytest.importorskip("langgraph.graph")
+ manuscript = (
+ Path(__file__).resolve().parents[2]
+ / "docs/thesis/system-design-implementation.md"
+ ).read_text(encoding="utf-8")
+ section = manuscript.split("", 1)[1]
+ snippet = section.split("```python\n", 1)[1].split("```", 1)[0]
+ namespace = {}
+ # This is trusted repository prose, executed exactly as displayed.
+ exec(compile(snippet, "thesis-langgraph-example", "exec"), namespace)
+ result = namespace["graph"].invoke({"text": text, "report": ""})
+ assert result["text"] == text.strip()
+ assert result["report"] == expected_report
diff --git a/tests/examples/test_thesis_python_walkthrough.py b/tests/examples/test_thesis_python_walkthrough.py
index 4d3d7bda..3e4a2df0 100644
--- a/tests/examples/test_thesis_python_walkthrough.py
+++ b/tests/examples/test_thesis_python_walkthrough.py
@@ -10,9 +10,11 @@ from typing import Any, cast
import pytest
-from wf_client import App, Deployment, Run
+from wf_api.durable_context import durable_workflow_api
+from wf_client import App, Deployment, Run, Schedule
from wf_client.protocols import WorkflowClientPort
from wf_config import load_workflow_config
+from wf_scheduling.store import FileScheduleStore
from wf_server.config import build_workflow_server_from_workflow_config
ROOT = Path(__file__).resolve().parents[2]
@@ -32,7 +34,11 @@ async def test_thesis_python_session_preserves_report_and_repair(
config = load_workflow_config(ROOT / "examples/report_workflow/wf.config.json")
config.server.store.root = tmp_path / "store"
server = build_workflow_server_from_workflow_config(config)
- app = App._from_port(cast(WorkflowClientPort, server.api))
+ # Enable real schedule persistence without starting a background scheduler:
+ # this session verifies registration, while another test exercises dispatch.
+ schedule_store = FileScheduleStore(config.server.store.root)
+ api = durable_workflow_api(server.context, schedule_store=schedule_store)
+ app = App._from_port(cast(WorkflowClientPort, api))
monkeypatch.chdir(ROOT)
manuscript = THESIS.read_text(encoding="utf-8")
@@ -84,3 +90,14 @@ async def test_thesis_python_session_preserves_report_and_repair(
for displayed in displayed_outputs:
assert displayed in diagnostic, "Displayed output differs from the session"
assert namespace["trace"].frames
+ schedule = namespace["schedule"]
+ assert isinstance(schedule, Schedule)
+ assert schedule.deployment_id == deployment.deployment_id
+ assert schedule.max_steps == 100
+ assert schedule.trigger["kind"] == "oneshot"
+ persisted = schedule_store.get_schedule(schedule.schedule_id)
+ assert persisted is not None
+ expression = persisted.input_bindings[0].expression
+ assert expression.kind == "literal"
+ assert expression.value == namespace["notes"]
+ assert namespace["history"]["occurrences"] == []