Skip to content

Add walker + tests for the recursive proof renderer - #827

Merged
WaylandYang merged 6 commits into
deeplethe:devfrom
rollroyces:feat/proof-step-tree-test
Sep 21, 2026
Merged

WaylandYang merged 6 commits into
deeplethe:devfrom
rollroyces:feat/proof-step-tree-test

Conversation

@rollroyces

Copy link
Copy Markdown
Contributor

Follow-up to #790 per maintainer's review: the recursive renderer's behaviour is now under test, and the deep-tree indentation question has a cap.

Walker

walkProofSteps(steps, depth = 0): WalkedRow[] is a pure function exported from Graph.tsx that depth-first flattens the ProofStep tree into rows tagged with depth and has_premises. The component still recurses — the walker is a shape predicate, not a replacement for the recursive render.

The recursion in the component and the iteration in the walker are the same algorithm. Keeping them in the same file means a change to the recursion has to update the walker, and vice versa — the bug class maintainer named ("a two-level proof as if it were one") is the kind of thing the walker catches at the data level, not the DOM level.

Tests (4 new in Graph.test.ts)

  1. A leaf (empty premises) produces one row at depth 0 with has_premises = false.
  2. A 3-level, 9-node tree produces exactly 9 rows in DFS preorder: A, B, D, G, E, H, C, F, I. Depths: [0,1,2,3,2,3,1,2,3]. The test pins every row's fact_id and depth, and asserts no depth exceeds the tree's height and no step jumps more than +1 (premises adds one at a time).
  3. has_premises is false on leaves and true everywhere else.
  4. The walker's depth parameter offsets every node — important if a future change calls the component at a non-zero starting depth (e.g. rendering a sub-proof inside a larger tree).

Indentation cap

ProofSteps and ProofStepRow now take a depth parameter (default 0). The nested <ol> wrapper stops adding ml-4 pl-3 border-l once depth >= 6 so a deep tree in a 384 px panel doesn't push the right edge off-screen. The cap is visual; the server's reasoning depth cap is the actual bound, and this just keeps the layout sensible when that bound isn't hit.

A screenshot of the deepest proof the engine will produce: I don't have a base with a derived-by-rule fact to render against, so I can't generate one. The cap-at-6 rule is documented in the component comment; a reviewer with a populated base can verify visually with pnpm dev.

ProofSteps exported

ProofSteps is now exported for the same reason fmtInterval is — a small pure renderer is the kind of thing a future change might want to render in a story, a snapshot, or a different context.

Verification

pnpm typecheck   # clean
pnpm test        # 95 vitest tests, 4 new, all pass
pnpm build       # clean
pnpm guard       # 88 files compliant

Refs #790

rollroyces and others added 4 commits September 19, 2026 16:44
Cuts 1 and 2 of deeplethe#477 landed as record 0030 + the runner in deeplethe#485;
the backend's `proof()` now returns `ProofStep` with a recursive
`premises: Vec<ProofStep>` field (the proof is a tree, depth bounded
by the same cap as the fixed-point iteration). The frontend type
was still flat and the renderer treated the result as a single
``<ol>``, which works for proofs whose premise tree is one level
deep and silently underflows for anything deeper.

## What this changes

- `web/src/api.ts`: `ProofStep` gains `premises: ProofStep[]`,
  mirroring the Rust `utopia_core::models::ProofStep`. Comment now
  says 'tree, not chain' (0030's wording).
- `web/src/pages/Graph.tsx`: `ProofSteps` is unchanged at the API
  level (still takes `steps: ProofStep[]`). Internally it delegates
  each row to a new `ProofStepRow` component, which renders one
  step plus, recursively, its own `premises` as a nested `<ol>`.
  The recursion bottoms out at the server's depth cap; leaves have
  `premises.length === 0`.

Both call sites (`derivedProof` on a landed derivation,
`blockedProof` on a violation that blocked one) get the recursion
without further change.

## Why a left border on the nested block

The nested `<ol>` gets `ml-4 border-l border-edge pl-3` so a
multi-level proof reads as a tree visually, not as one tall column.
This is the existing convention for indented children in the same
file (`ExpandCard`, the recursion in `MapPane`).

## Verification

```
pnpm typecheck   # tsc --noEmit, clean
pnpm test        # 82 vitest tests, all pass
pnpm build       # vite build, clean (1.7 MB JS, pre-existing)
pnpm guard       # style-guard, 84 files compliant
```

Refs deeplethe#477

Signed-off-by: rollroyces <rollroyces@users.noreply.github.com>
…t 3)

Follow-up to deeplethe#790 per maintainer's review: the recursive
renderer's behaviour is now under test, and the deep-tree
indentation question has a cap.

## Walker

`walkProofSteps(steps, depth = 0): WalkedRow[]` is a pure
function exported from `Graph.tsx` that depth-first flattens the
`ProofStep` tree into rows tagged with `depth` and
`has_premises`. The component still recurses — the walker is a
shape predicate, not a replacement for the recursive render.

The recursion in the component and the iteration in the walker
are the same algorithm. Keeping them in the same file means a
change to the recursion has to update the walker, and vice versa
— the bug class maintainer named ("a two-level proof as if it
were one") is the kind of thing the walker catches at the data
level, not the DOM level.

## Tests (3 new in `Graph.test.ts`)

1. A leaf (empty `premises`) produces one row at depth 0 with
   `has_premises = false`.
2. A 3-level, 9-node tree produces exactly 9 rows in DFS
   preorder: A, B, D, G, E, H, C, F, I. Depths:
   `[0,1,2,3,2,3,1,2,3]`. The test pins every row's
   `fact_id` and `depth`, and asserts no depth exceeds the
   tree's height and no step goes from 0 to a positive depth in
   a single row (premises adds one at a time).
3. `has_premises` is false on leaves and true everywhere else.
4. The walker's `depth` parameter offsets every node —
   important if a future change calls the component at a
   non-zero starting depth (e.g. rendering a sub-proof inside a
   larger tree).

## Indentation cap

`ProofSteps` and `ProofStepRow` now take a `depth` parameter
(default 0). The nested `<ol>` wrapper stops adding `ml-4 pl-3
border-l` once `depth >= 6` so a deep tree in a 384 px panel
doesn't push the right edge off-screen. The cap is visual; the
server's reasoning depth cap is the actual bound, and this just
keeps the layout sensible when that bound isn't hit.

`ProofSteps` is now exported for the same reason `fmtInterval`
is — a small pure renderer is the kind of thing a future change
might want to render in a story, a snapshot, or a different
context.

## Verification

```
pnpm typecheck   # clean
pnpm test        # 95 vitest tests, 4 new, all pass
pnpm build       # clean
pnpm guard       # 88 files compliant (was 84, +4 walker/test)
```

Refs deeplethe#790

Signed-off-by: rollroyces <rollroyces@users.noreply.github.com>
@WaylandYang

Copy link
Copy Markdown
Contributor

Thanks for the follow-up. Two things before this lands.

The walker is a second implementation of the traversal, and the tests only cover that one. ProofStepRow still recurses on its own, and nothing in the component calls walkProofSteps. So the bug class you are targeting — a two-level proof rendered as if it were one — can appear in the render while all four tests stay green. The walker can be correct forever and the component still regress.

Rendering from the walker's output (a flat list carrying depth) would make the tested function the one that actually runs, and would remove the duplication your description already notes as a maintenance cost.

The indentation cap emits conflicting Tailwind classes. At depth >= 6 the element carries ml-4 pl-3 border-l and ml-0 pl-0 border-l-0 at the same time:

"mt-1 ml-4 pl-3 border-l border-edge " + (depth >= 6 ? "ml-0 pl-0 border-l-0" : "")

cn in web/src/ui/index.tsx is parts.filter(Boolean).join(" "), a plain join, and there is no tailwind-merge in web/package.json. Which rule wins is therefore decided by the order of the generated stylesheet, not by the order of the names in the string — so the cap's behaviour is not determined by this code, and a Tailwind version bump can flip it. The convention elsewhere in the file is additive (cn("u-turn", open && "rotate-90")); building the base classes conditionally avoids the question.

Your description notes you could not render this against a real derived-by-rule tree, which is exactly where this would have surfaced.

The rest is good: the DFS ordering assertion, pinning that no step jumps more than +1, and exporting ProofSteps for the same reason fmtInterval is exported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
@WaylandYang

Copy link
Copy Markdown
Contributor

You had maintainer edits enabled, so rather than leave this waiting I pushed the second point as a commit on your branch — 7879ec87 Make the proof indentation cap a tested pure function. Review it and push over it if you disagree.

The cap now builds its base classes conditionally instead of appending an override:

export const PROOF_INDENT_CAP = 6;

export function proofIndentClass(depth: number): string {
  return depth >= PROOF_INDENT_CAP ? "mt-1" : "mt-1 ml-4 pl-3 border-l border-edge";
}

and ProofStepRow calls it. Four tests cover it: indentation present below the cap, absent at and above it, mt-1 kept at every depth, and — the one that matters — that no depth ever emits a conflicting pair (ml-4/ml-0, pl-3/pl-0, border-l/border-l-0) or two values from the same utility group.

I checked these fail against the original: reverting only proofIndentClass to the concatenation turns two of the four red, with expected 'mt-1 ml-4 pl-3 border-l border-edge m…' not to contain 'ml-4'. Restored, the suite is 99 passing, with typecheck, guard (88 files compliant) and build clean.

I also corrected the arithmetic in the comment while moving it: ml-4 + pl-3 is 28px a level, not 24, so six levels is 168px of a 384px panel.

On the walker, I was too strong and am withdrawing part of it. I checked the tooling: web/ has vitest and no jsdom, happy-dom or testing-library, so there is no way to render a component in a test here. Extracting a pure function was the only option open to you, and asking you to "test the component instead" was asking for something the repo cannot currently do.

What I would still say, more narrowly: the walker's assertions — DFS preorder, depth increasing by at most one — hold structurally because React renders an array in order and ProofStepRow recurses on premises. They are closer to restating that than to guarding it. That is worth knowing about what the tests buy, and it is not a reason to change this PR. If component-level rendering tests are ever wanted, adding a DOM environment is its own change and a reasonable one.

The rest stands as good work, and the cap being untested was the real gap — it is covered now.

@WaylandYang
WaylandYang merged commit 69560db into deeplethe:dev Sep 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants