AREST Developer Docs

Self-contained reference for building on arest. FORML 2 readings compile to a database schema, constraint rules, state machines, and a REST API with HATEOAS, all from one β-reducer over Backus's FFP algebra.

View the Project on GitHub graphdl/arest

25 — The Entity Navigation Graph

Theorem 4 (chapter 04, HATEOAS as Projection) and its navigation half, Theorem 4b (chapter 07), give the local view: for one entity e, the links it carries — its valid transitions plus one navigation URI per binary fact type it participates in. That is a neighbour-star: from e, here are the adjacent resources.

A user interface, and an ORM diagram, need the global structure the star is a slice of: which noun is a root, what drills into what, what sits beside what. That structure is the Entity Navigation Graph, and AREST derives it — like everything else — from the model itself, with no hand-authored menu and no separate cardinality annotation. It lives in the shared canon as the system:nav_* family (engine/shared/arest.canon:42554396); this chapter is its specification.

The graph is one structure with two renderings: unfolded in time it is the navigation map (how you drill an app); embedded in space it is the ORM diagram layout. The same edge classification drives both.

Cardinality is read off uniqueness — it is not stored

AREST never stores a “1:n” annotation. Following ORM (Halpin & Morgan, Information Modeling and Relational Databases, 2nd ed., §4 on uniqueness), a fact type’s cardinality is its uniqueness-constraint pattern:

So the classifier only has to look at the fact type’s uniqueness constraints and its arity. The canon does exactly that:

The edge classification — nav_kind

system:nav_kind (arest.canon:4310) reduces the above to one of three tags:

nav_kind(ft) =  collection   if  nav_ucs(ft) is empty        -- m:n
                peer         if  nav_spanning(ft)             -- 1:1
                child        otherwise                        -- 1:n (functional)

The determinant/dependent direction of a child edge is refined by which role carries the subset UC; nav_kind returns the class, and the projection (nav_of, below) resolves the direction for a concrete entity.

The edge and the graph — nav_control and nav

The Entity Navigation Graph of an app is G = ⋃_n nav(n) — every noun’s edge set, each edge tagged child / peer / collection.

Rendering 1 — the navigation map

Take the sub-graph of child edges. It is (barring modelled cycles) a DAG on the functional relationships. A navigation map is a walk of it:

  1. Roots are nouns reachable as an entry (top-level collections — those with an incoming collection/peer affordance but not themselves a functional dependent), or an explicitly chosen home noun.
  2. From a node, descend each child edge — 1:n into the dependent noun, then that noun’s own child edges, recursively: the “1:n → that n’s 1:n → …” spanning descent.
  3. At each node, its peer and collection edges are siblings — lateral affordances rendered beside the drill path, not descended.
  4. A node whose only out-edges are peer/collection is a leaf of the drill.

This is the whole navigation-map generation algorithm: no per-screen menu is maintained; the legal next moves at any node are recomputed from nav(n) and split by nav_kind into descend (child) versus beside (peer/collection).

Rendering 2 — the ORM diagram layout

The same graph, embedded in the plane, is the ORM diagram’s layout engine:

The drill order of the navigation map and the top-to-bottom rank order of the diagram are the same ordering of the same graph — which is why one algorithm served both the nav-map generator and the diagram layout engine.

system:nav is the schema-level graph. system:nav_of (arest.canon:3233), which Theorem 4b uses, is its instance-level projection: for a concrete entity e of noun n, each out-edge ⟨kind, target⟩ of nav(n) becomes a concrete link in e’s representation —

which is exactly links_full(e, n, status) = nav(e) ∪ transitions(status(e)) of chapter 07. Theorem 4’s per-entity links are the shadow this schema graph casts on a single entity. The graph is primary; the HATEOAS links are its projection.

Worked example (the classification test)

engine/tests/test_entity_view.py::test_nav_labels_controls_by_the_uniqueness_structure pins the whole classification on one fixture. A Task noun plays three fact types:

fact type uniqueness constraint nav_kind
Task_blocks_Task spans both roles (the pair is the key) peer (1:1)
Task_owns_Widget spans only Task’s role child (1:n — Widgets are children of the key)
Task_notes none collection (m:n)

and asserts system:nav("Task", D) reduces to exactly

(("peer",       "Task_blocks_Task", ("Task",)),
 ("child",      "Task_owns_Widget", ("Widget",)),
 ("collection", "Task_notes",       ("Note",)))

with system:nav("Zebra", D) = () for an unplayed noun, and system:links("Task", e, D) = ⟨nav(e), transitions(status(e))⟩ closing the union of Theorem 4. The classification above is therefore executable and verified, not a description of intent.

Real-store behavior (2026-07-12): RMAP absorption hides the 1:n edges

The classification above is correct on an un-absorbed schema — the fixture leaves rmapColumns empty, so every fact type stays own-table and nav_kind sees its uniqueness constraint. On a real compiled store it degenerates, and running the wired resident nav verb over the tasks app makes this concrete:

Root cause (isolated on the real D, 2026-07-12): system:ev_ownfts IS the excluder. system:nav draws its candidate pool from system:ev_ownfts (arest.canon:4794) = factType minus rmapColumns. On the real tasks store 23 Task fact types are absorbed into rmapColumns (columns 2–24), including Task_is_parked_for_Park_Reason and Task_is_currently_in_Status, so the pool loses them before classification. A stage-by-stage trace through the real reducer settled it:

stage on real Task verdict
nav_playedfts("Task") includes both absorbed fts (32 fts) played-set correct
ev_ownfts("Task",…) excludes both (absorbed stripped) the drop is here
Filter(member …) would keep them not the excluder
nav_kind("Task_is_parked_…") "child" would classify right if it arrived

The absorbed functional fts are exactly the child/peer edges, so they never fire. (An earlier note here claimed the excluder was “downstream” and that an ev_allfts substitution was a real-store no-op — both were measurement errors: the Python oracle double-wrapped the store (load_sqlite already returns a lam D), and the Rust resident reading was a host-twin divergence, not the canon’s behavior. Corrected.)

The fix (applied, arest.canon:4389): swap system:nav’s pool from ev_ownfts to the full factType fetch. Absorption is a storage decision (own table vs. column); it must not change navigation — an absorbed functional ft is still a reference edge, the strongest nav link. ev_ownfts itself is unchanged (its other caller system:ev_facts correctly needs own-table-only). Proven in the real reducer: nav(“Task”) goes 7 → 30 edges, {collection: 22, child: 8}, purely additive (all 7 baseline preserved), surfacing ('child','Task_is_parked_for_Park_Reason',('Park Reason',)), ('child','Task_has_Owner',('Owner',)), etc.; the fixture regression still passes. (Side note: it also surfaces 15 absorbed unary booleans as target-less ('collection', ft, ()) edges — consistent with how own-table unaries already appear; add an arity-≥-2 filter if unary flags are unwanted as nav links. Design refinement, not a bug.)

Status (2026-07-13)

Reusable real-D oracle recipe (no double-wrap): D = load_sqlite(".../tasks.db") (already a lam D — do NOT to_lam it); from_lam(R(A("system:nav"), _S(A("Task"), D))). Trace any sub-step by applying system:nav_playedfts / system:ev_ownfts / nav_kind to the same D. Scratch scripts in the job tmp: navreal3.py, and the agent’s navtrace.py / navtest_fix.py.