Skip to content

A rule reads what a rule concluded - #485

Merged
WaylandYang merged 3 commits into
devfrom
feat/a-rule-may-read-a-rule
Sep 8, 2026
Merged

WaylandYang merged 3 commits into
devfrom
feat/a-rule-may-read-a-rule

Conversation

@WaylandYang

Copy link
Copy Markdown
Contributor

Cut 1 of #477. Stacked on #484.

docs/decisions/0027-a-rule-may-read-what-a-rule-concluded.md, plus its index row. No code — this is the argument, so it can be disagreed with before the runner is written.

A gas-bearing well with a completion date is a producer. That second criterion cannot be written today: a rule's conclusion is not something another rule can read, so the hydrocarbon condition gets copied into the producer rule and two rules end up sharing a premise that nothing in the base says they share.

The objection this has to answer is written into the fact_derivations DDL by 0013: premises are always assertions, or a re-run depends on what the last round left behind. The record does not waive it. The feedback happens inside one materialize() call, in memory — a fixed point that starts from the asserted facts every time and never queries derived_facts as input. A run stays a pure function of (facts, rules, axioms), which is what that objection was actually protecting; it does not follow from "premises are assertions", it follows from "the run does not read its own past output", and those coincided only while there was one pass.

What the record settles:

  • A fixed point, not an orderingMAX_DEPTH rounds, sorted frontier, same shape as derive(), because otherwise whether A → B → C fires depends on rule load order.
  • A derived typing enters as a premise, not as a filter. entities.type_id carries no interval, but a derived membership does — an entity that is a B from 2019 to 2022 must not satisfy a B rule on a 2024 reading. Putting the typing in the premise list makes validity() narrow the conclusion for free, and is what makes retirement work with no code written for it.
  • A rule may read its own conclusions. Forbidding it needs a rule-dependency graph that cannot be computed (a rule's inputs are predicates, not rules). The loop is safe: the derivable space is finite, rounds only add, and a repeated key never re-enters the frontier.
  • The proof becomes a tree. fact_derivations gains a nullable premise_derived_id with a CHECK that exactly one premise column is set, and proof() becomes a walk. The ProofStep comment that says otherwise changes with it.
  • Retirement cascades and costs nothing — recompute is total, so a chain whose leaf changed simply is not rebuilt and falls out of wanted in the same statement.
  • Row identity resolves after the diff: the fixed point carries provisional ids, and premise ids are rewritten to final row ids before derivations are written.

Two open points are named rather than hidden: a kept row can keep a stale proof (already true with asserted premises, easier to hit with chains), and how deep a proof is worth drawing.

Runner and real-database tests for A → B → C, for a cycle, and for a leaf reading taking the chain with it are the next cut.

🤖 Generated with Claude Code

@WaylandYang

Copy link
Copy Markdown
Contributor Author

Cut 2 landed on the same branch: the runner.

  • The fixed point lives in materialize(). Each round evaluates every rule against a pool that starts as the asserted attribute facts and grows with the round's conclusions; it stops when a round produces no new key. Bounded by MAX_DEPTH, and rules now load ORDER BY r.id so which proof wins a tie does not depend on hash order.
  • A concluded typing joins the subject scope as a condition, not a filter. scoped_by_conclusion() appends an is_a in {…} condition to every groupscope AND ((A and B) or C) distributes to (scope and A and B) or (scope and C), so 0026's one level of disjunction is preserved. The derived typing therefore lands in the premise list and validity() narrows the conclusion to the window the entity actually was a B. An asserted entities.type_id has no interval and stays a filter.
  • Migration 0040 makes premise_fact_id nullable, adds premise_derived_id, and CHECKs exactly one. It also adds a derivation_premises view, because six places read premises and writing JOIN facts … UNION ALL JOIN derived_facts … six times means one of them silently lists a premise short.
  • proof() walks the tree. ProofStep gains derived and a nested premises.
  • Row identity resolves after the diff, as the record describes, and derivations are written in a second pass — a chained premise can point at another row in the same batch, and wanted is a HashMap with no order to rely on.
  • The kept-row stale proof is closed, not deferred: the run reads the stored premises of every kept row in one query and rewrites the ones that differ (reproved in the report).

Five database tests in crates/utopia-store/tests/a_rule_reads_what_a_rule_concluded.rs:

  • Well → GasBearingWell → Producer chains, and the producer's premises are one asserted (premise_fact_id) and one derived (premise_derived_id), with proof() expanding the derived step one level further.
  • A concluded class holds only where it was concluded: gas-bearing 2020–2021 plus a 2022 completion reading gives no producer.
  • Raising the first rule's threshold retires both rows in one pass, with nothing written to make the cascade happen.
  • A rule reading its own conclusion settles well short of MAX_DEPTH — the second round reproduces a key it already has.
  • A conclusion whose reading was replaced keeps its row and gets a new proof.

cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings and the full suite (68 test binaries) are clean against a real database.

The record's status line and its README row are updated; the "kept row can keep a stale proof" open point is struck through as closed.

Still cut 3: the rule table saying which rules feed which, and the entity panel drawing a proof more than one level deep.

@WaylandYang WaylandYang changed the title A rule may read what a rule concluded, written down A rule reads what a rule concluded Sep 8, 2026
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch from 977542a to 2a4a32c Compare September 8, 2026 03:15
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch from 67c9f14 to 2aace5e Compare September 8, 2026 03:15
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch from 2a4a32c to da712cd Compare September 8, 2026 03:53
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch from 2aace5e to 2829f86 Compare September 8, 2026 03:53
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch from da712cd to b359041 Compare September 8, 2026 04:10
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch from 2829f86 to 2613d6a Compare September 8, 2026 04:10
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch from 2613d6a to 5127dd4 Compare September 8, 2026 04:41
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch from b359041 to 3ee5623 Compare September 8, 2026 04:41
WaylandYang added a commit that referenced this pull request Sep 8, 2026
Three records were written as 0026-0028 on three branches on the same day. By the order the pull requests were opened, the adjudication stack (#473-#475) keeps 0026-0028, the rules stack (#484, #485) becomes 0029 and 0030, and the event record (#487) becomes 0031.

Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch from 5127dd4 to ea36c26 Compare September 8, 2026 05:03
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch 2 times, most recently from 04df549 to d1c7abf Compare September 8, 2026 05:08
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch from ea36c26 to 8b9509a Compare September 8, 2026 05:08
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch from d1c7abf to e11450f Compare September 8, 2026 05:12
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-say-or branch 2 times, most recently from 3219159 to e78ec78 Compare September 8, 2026 05:21
Base automatically changed from feat/a-rule-may-say-or to dev September 8, 2026 05:24
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
@WaylandYang
WaylandYang force-pushed the feat/a-rule-may-read-a-rule branch from e11450f to 4c21f28 Compare September 8, 2026 05:27
@WaylandYang
WaylandYang merged commit cb2bab7 into dev Sep 8, 2026
4 checks passed
@WaylandYang
WaylandYang deleted the feat/a-rule-may-read-a-rule branch September 8, 2026 05:30
WaylandYang added a commit that referenced this pull request Sep 19, 2026
Cuts 1 and 2 of #477 landed as record 0030 + the runner in #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 #477

Signed-off-by: rollroyces <rollroyces@users.noreply.github.com>
Co-authored-by: rollroyces <rollroyces@users.noreply.github.com>
Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com>
WaylandYang added a commit that referenced this pull request Sep 21, 2026
* Render nested proof trees in the entity panel (#477 cut 3)

Cuts 1 and 2 of #477 landed as record 0030 + the runner in #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 #477

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

* Add walker + tests for the recursive proof renderer (#477 cut 3)

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 (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 #790

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

* Make the proof indentation cap a tested pure function

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

---------

Signed-off-by: rollroyces <rollroyces@users.noreply.github.com>
Signed-off-by: WaylandYang <wayland0916@gmail.com>
Co-authored-by: rollroyces <rollroyces@users.noreply.github.com>
Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com>
Co-authored-by: WaylandYang <wayland0916@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant