Files
rose-ash/plans/relations-as-posts.md
giles 64106c89fa
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 21s
plan: design parameterised relations — Slice 6 (role signature: a+b+c) & Slice 7 (algebra: d)
Capture the Relation<…> design from the discussion. The reframe: the parameters split
into two halves — the role SIGNATURE (shape of a tuple: per-role type a, arity b,
cardinality c) and the relation's ALGEBRA (behaviour: transitivity/symmetry/inverse/
sub-relations d). A relation is Relation<signature>; today's binary typed relations are
the degenerate 2-role case.

Slice 6: generalise :rel to a :roles signature; (a) per-role type = the declares-anchor
made explicit, (b) arity needs reification (instance-posts) for n-ary, (c) cardinality by
counting. Nominal variance, JIT caveat for n-ary role iteration.

Slice 7: declared algebraic properties with GENERIC closure (retires the hardcoded
is-a/subtype closure — OWL property characteristics); real inverse relations; sub-relations.
Decidable core stops here; defined-by-rule + cross-role predicates fenced behind the
predicate-language decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:26:30 +00:00

13 KiB
Raw Blame History

Relations as posts — declared, inherited, and eventually algebraic

Principle

Everything is a post in one graph: content-posts, type-posts, relation-posts, and (later) constraint-posts. Nothing about typing is hardcoded — a type-post declares which relations it anchors, declarations are inherited down the type closure, and every candidate set / validation is a transitive graph query (lib/relations). This closes the meta-circular loop the typing plan gestured at: the type system describes itself in its own graph.

Supersedes the hardcoded :candidates "types"/"tags"/"all" field of host/blog-rel-kinds.

Why (the wrinkle that started this)

Candidates for is-a/subtype-of were instances-of("type") — the instances that are types, but NOT the type-defining posts themselves (type, tag, article are wired with subtype-of, no is-a edge, so they're not instances of type). So the picker offered tutorial (is-a tag) but never tag/article/type — the things you most want to say a post is-a. The fix is to ask the right question: a candidate is anything that inherited the relation's object-end declaration from the anchor, which includes the roots.

Model

  • A declaration is an edge T --declares--> R: type-post T anchors relation R at its object end ("you may point at T with R"). Seed: type declares is-a, type declares subtype-of, tag declares tagged. related has no declaration.
  • Candidate set for relating under R = the down-closure of R's anchors through inverse(is-a) inverse(subtype-of) (a post is a candidate iff it is, transitively, an instance-or-subtype of an anchor — or IS one). No anchors ⇒ every post (related).
    • is-a/subtype-of: anchors {type} ⇒ the whole type closure (roots + subtypes + instances). Wrinkle fixed.
    • tagged: anchors {tag} ⇒ the tags.
    • related: no anchor ⇒ all posts.

Roadmap

Slice 1 — declarations + candidate-by-inheritance — DONE

  • Seed declares edges; add host/blog--reach-down (down-closure) and rewire host/blog--candidate-pool to be declaration-driven. :candidates becomes vestigial.
  • Wrinkle fixed: the type roots now appear as is-a candidates.

Slice 2 — relations as first-class posts — DONE

  • relation root + is-a/subtype-of/tagged/related seeded as posts (each is-a relation) owning their metadata in a :rel slot (:symmetric :label :inverse-label). host/blog-rel-kinds / kind-spec / kind-symmetric? now read it; the static registry is gone. host/blog--rel-slugs = host/blog-in "relation" "is-a" (cheap, flat).
  • Perform budget under http-listen (the hard lesson): a durable read inside the render VM raises VmSuspended, and too many per request 500s the page. Two fixes: (1) relation metadata is loaded into an in-memory cache at boot (host/blog-load-rel-kinds!, like load-edges!) so kind-spec is pure; (2) the initial edit page renders its pickers EMPTY (the load trigger fills each) — only the relate/unrelate FRAGMENT server-renders candidates (with-cands flag), so one page render doesn't do candidate-get × every picker. Benign single-perform suspend/resume still logs VmSuspended but returns 200.
  • Live JIT gotcha (cost real time): the serving-mode JIT drops all-but-first when map/for-each-ing a function-produced list — building rel-kinds that way rendered only 1 of 4 editors live, while conformance + the ephemeral server passed. So host/blog-rel-kinds is a VALUE the boot populates and the cache loads are UNROLLED. Conformance green ≠ correct live — verify the rendered edit page. (Re-fold the enumeration once plans/jit-bytecode-correctness.md lands.)

Slice 2.5 — picker title reads are O(page), not O(pool) — DONE

  • relate-candidates computes the available candidate SLUGS (slug-sorted, no per-candidate read), then reads titles ONLY for the page it returns. On the unfiltered path (q="" — the initial picker load AND every editor server-fill, the common case) that's ~limit reads instead of one-per-post — killing the durable-read churn under http-listen. A filter (q≠"") still resolves titles across the pool (it matches on the title), but that's the interactive path.
  • A boot-time slug→title cache would make even the filter O(1)-perform, BUT it's blocked for now: there's no bulk KV read, and a per-post host/blog-get loop at boot hits the JIT bug (a durable read inside a boot loop drops all-but-first — load-edges! only works because its loop body is perform-free). Revisit with a bulk read or once the JIT lands.

Remaining follow-ups: subject-end declarations (who may be the source); a proper relation-subtype closure when relations get subtyped; the boot title cache above.

Slice 3 — typed relations (target-type constraints) — DONE

  • The declaration's declares-anchor IS the target-type constraint: is-a/subtype-of (anchored by type) require a type object; tagged (anchored by tag) a tag. A new wrote relation needs only a Work declares wrote edge — fully data-driven.
  • host/blog--valid-object?(kind, other) = other ∈ candidate-pool(kind) — the SAME set the picker offers, so picker and validation agree by construction. relate-submit now enforces it (an invalid target is a silent no-op, like the other guards); related (no anchor) accepts any post. The picker never offers an invalid target, so this guards crafted/API requests — the jump from "candidate set" to an enforced relation schema.
  • NOTE: host/blog-relate! (direct/seed) stays UNVALIDATED — the seed needs to write X is-a relation where relation isn't under type. Validation is a handler boundary.

Slice 4 — type algebra — DONE (intersection ∧ union)

  • An algebraic type is a post with operand edges: conj edges (intersection members), disj edges (union members). host/blog-instances-of-expr computes its EXTENT from the operands' extents by set intersection / union, RECURSIVELY — so operands can themselves be algebraic (meta-circular; tested with (tag ∧ article) ∧ tag). host/blog-is-a-expr? generalises is-a? to type expressions. host/blog-make-and! / make-or! build them.
  • Binary today (nth 0/1, no fold over operands — robust on the serving JIT); n-ary fold is a follow-up once iteration-with-perform is JIT-reliable.
  • Operand edges are KV-only (host/blog--add-edge-kv!, read via host/blog-out), NOT in lib/relations — feeding extra kinds into the Datalog graph blows up its per-query re-saturation; load-edges! skips conj/disj on replay for the same reason.
  • Refinement {x : T | φ(x)} (a type-post with a :constraint predicate) → Slice 5, with constraints-as-posts. (Process note: a sibling loop running heavy conformance saturates the box; host conformance can EXIT 124 purely from CPU contention — use timeout 1200.)

Slice 5 — refinement types (schemas ON the type-post) — DONE

  • A type-post carries its schema in a :schema slot (a list of {:block :msg} rules — a refinement {x : T | x has these blocks}). host/blog-schema-of reads it off the post; the hardcoded host/blog-type-schemas table is gone. A NEW refinement type is pure data: give a type-post a :schema (host/blog--set-schema!) and its instances are validated on save against it — no code. Tested with a guide type requiring a pre block.
  • Save-time validation (type-issues/type-valid?, the only callers, in the SAVE request) unions the schemas of a post's full transitive type set — unchanged, just sourced from the posts. schema-of reads the post (a durable read) — fine in the save request, never render.
  • host/blog-put! now MERGES over the previous record, so editing a post's title/content doesn't nuke its :schema / :rel metadata (also closes the Slice 2 "edit drops :rel" gap).
  • article's schema migrated onto the article post (set-schema! at boot — a single read+write, not a loop, so boot-JIT-safe; idempotent, handles the already-seeded article).
  • FUTURE: arbitrary predicate constraints (not just required blocks); constraints as their own posts; relation cardinality (is-a single-valued?) as a declared constraint.

Parameterised relations (DESIGN — Slices 6 & 7)

The next axis: Relation<…>. The key reframe is that the obvious parameters aren't separate <N>s — they split into two halves, and they compose into one coherent thing:

  1. The role SIGNATURE (the shape of a tuple) — Slice 6 (a + b + c).
  2. The relation's ALGEBRA (how it behaves) — Slice 7 (d).

A relation is Relation<signature>, where a signature is an ordered list of roles, each role carrying a type and a cardinality; the signature's length is the arity. Today's binary typed relations are the degenerate 2-role case — backward-compatible, nothing gets thrown away. Prior art to borrow (and stay decidable within): Codd / ER reified relationships (signature), OWL property characteristics (algebra), Datalog / relation algebra (derived relations — the undecidable frontier; fence it). Decidability rule of thumb: concrete

  • algebraic role-types and counts stay decidable; arbitrary predicates / recursive rules don't.

Slice 6 — the role signature (a + b + c)

Generalise the relation-post's :rel slot from {:symmetric :label} to a :roles list — {:roles [{:name :type :card} …]} — driving picker candidates, validation, and arity per-role:

  • (a) per-role type — each role's :type is a type-expr (so it can be algebraic: Relation<Work ∧ Published>). The object-role's type IS today's declares-anchor — make it explicit. valid-object? becomes per-role is-a-expr? against :type.
  • (b) arity = (len roles). Binary stays the fast src|kind|dst edge path; n-ary needs reification: a relation instance becomes its own post with role edges (subject→X, object→Y, recipient→Z) — on-brand (we made relation kinds posts; now instances too), but a SECOND representation alongside the binary edges, not a tweak. Qualifiers (Wikidata- style) then come free as extra roles.
  • (c) cardinality:card per role (min/max; functional = max 1, required = min 1), enforced on relate by counting. Composes with Slice 5 validation. No model change for binary.
  • Siblings: ordered roles (set vs list), keys/identity (which roles identify a tuple).
  • Layering (cheapest → deepest): (c) cardinality on the binary object-role → (a) explicit role-type + the 2-role signature abstraction → (b) reified n-ary (the real lift).
  • Variance: nominal, none initially — no structural subtyping of Relation<…> (covariance of parameterised types is a research project). JIT caveat: 2-role signatures are unrollable; n-ary role-iteration with per-role reads needs the cache/unroll treatment (Slice 2/5 lesson).

Slice 7 — relation algebra / characteristics (d)

The behaviour half — and (d) transitivity is special because we ALREADY hardcode it (is-a/subtype-of closure via lib/relations); declaring it generically removes code.

  • Algebraic properties declared on the relation-post (:transitive :symmetric :reflexive :antisymmetric :irreflexive), with the closure derived generically from them — OWL's property characteristics. subtype-of becomes "a declared transitive + antisymmetric relation" (a partial order), not a special case. :symmetric (already stored) folds in here.
  • Inverse relations — a real :inverse (not just the :inverse-label display hint): relating one auto-derives the converse, the way :symmetric writes both directions.
  • Sub-relations — relations subtyping relations (wrote subPropertyOf created): X wrote Y ⟹ X created Y. Same subtype-of machinery, over the relation root — meta-circular.
  • Decidable core stops here. Beyond-d, FENCED: defined-by-rule relations (composition, grandparent = parent ∘ parent — straight onto the Datalog substrate, but gate to stratified/bounded rules) and cross-role refinement predicates (start < end) — both need the predicate-language-vs-embedded-code decision first.

Open design questions (track as we go)

  1. Subject-end declarations — who may be the source of a relation (a root Thing?).
  2. Inheritance path — through is-a AND subtype-of downward (current choice); revisit if instances-of-instances as candidates surprises.
  3. Bootstrap / meta-circularityis-a needs is-a; seed relation-posts + Type is-a Type(?) idempotently, as the type seed already is.
  4. Costreach-down is a BFS of direct-edge scans; fine for a small blog, revisit with a lib/relations transitive query if the graph grows.