From b3804ce71260fb836f1193782207d516f7ddead6 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 29 Jun 2026 21:40:27 +0000 Subject: [PATCH] =?UTF-8?q?host:=20relations-as-posts=20slice=201=20?= =?UTF-8?q?=E2=80=94=20declaration-driven=20candidate=20pools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types declare which relation they anchor (type declares is-a/subtype-of, tag declares tagged) via a 'declares' edge; the picker's candidate set is the down-closure of a relation's anchors through is-a ∪ subtype-of. So is-a/subtype-of now offer the WHOLE type closure — the roots (type/tag/article) AND instances — fixing the wrinkle where only instances showed and you could never pick 'tag' or 'article' as a type. 'related' has no anchor → every post. Replaces the hardcoded :candidates "types"/"tags"/"all" with graph queries (host/blog--reach-down + the declares edges). Design + roadmap (relations as first-class posts, typed relations, type algebra, constraints) in plans/relations-as-posts.md. host conformance 283/283 (+5: is-a pool includes type roots, excludes plain posts, tagged anchored by tag, related = all, is-a relate-options offers Article). Co-Authored-By: Claude Opus 4.8 --- lib/host/blog.sx | 48 +++++++++++++++++----- lib/host/tests/blog.sx | 24 +++++++++++ plans/relations-as-posts.md | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 plans/relations-as-posts.md diff --git a/lib/host/blog.sx b/lib/host/blog.sx index b1b7464c..944d1ced 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -322,27 +322,55 @@ (host/blog-seed! "article" "Article" "(article (h1 \"Article\") (p \"A kind of post that must have a heading. A post that is-a article is checked against this type's schema on save — gradual typing: declaring the type adds the requirement, and the next edit must satisfy it.\"))" "published") - (host/blog-relate! "article" "type" "subtype-of")))) + (host/blog-relate! "article" "type" "subtype-of") + ;; relation DECLARATIONS (see plans/relations-as-posts.md). A type-post declares + ;; which relation it anchors at its OBJECT end ("you may point at me with R"); the + ;; picker's candidate set is the down-closure of a relation's anchors through the + ;; type graph, so the candidates for a relation are exactly the posts that inherit + ;; its declaration. `type` anchors is-a + subtype-of (you point at a type), `tag` + ;; anchors tagged (you point at a tag). `related` has no anchor → every post. + (host/blog-relate! "type" "is-a" "declares") + (host/blog-relate! "type" "subtype-of" "declares") + (host/blog-relate! "tag" "tagged" "declares")))) ;; ── relate picker (filterable, paginated candidate list) ──────────── ;; Candidates to relate `slug` to: every post except itself and ones already ;; related, narrowed by `q` (case-insensitive substring of title or slug), ;; title-sorted. One page is `host/blog--picker-limit` rows from `offset`. (define host/blog--picker-limit 20) -;; The candidate POOL for a kind comes from its registry :candidates: "all" posts, -;; or the members of a type ("tags" = instances of tag, "types" = instances of -;; type). Enumerating a type's members is O(#subtypes), not O(#posts). +;; Down-closure: every post reachable from `roots` by walking INVERSE is-a ∪ +;; subtype-of edges (i.e. instances and subtypes, transitively), roots included. +;; This is "everything that is, transitively, an instance-or-subtype of a root". +;; BFS over direct edges (host/blog-in); `seen` makes it cycle-safe and terminating. +(define host/blog--reach-down + (fn (roots) + (let loop ((frontier roots) (seen (list))) + (if (empty? frontier) + seen + (let ((t (first frontier))) + (if (contains? seen t) + (loop (rest frontier) seen) + (loop + (concat (rest frontier) + (concat (host/blog-in t "is-a") (host/blog-in t "subtype-of"))) + (concat seen (list t))))))))) + +;; The candidate POOL for relating under `kind` is DECLARATION-driven (see +;; plans/relations-as-posts.md): the down-closure of the posts that DECLARE `kind` +;; at their object end. So is-a/subtype-of (anchored by `type`) offer the whole type +;; closure — roots AND instances — and `tagged` (anchored by `tag`) offers the tags. +;; A relation with no declaration (e.g. `related`) offers every post. (define host/blog--candidate-pool - (fn (candidates) - (cond - ((= candidates "tags") (host/blog-instances-of "tag")) - ((= candidates "types") (host/blog-instances-of "type")) - (else (host/blog-slugs))))) + (fn (kind) + (let ((anchors (host/blog-in kind "declares"))) + (if (empty? anchors) + (host/blog-slugs) + (host/blog--reach-down anchors))))) (define host/blog--relate-candidates (fn (slug q kind) (let ((spec (host/blog--kind-spec kind))) - (let ((pool (host/blog--candidate-pool (get spec :candidates))) + (let ((pool (host/blog--candidate-pool kind)) (already (host/blog-out slug kind)) (ql (lower (or q "")))) ;; pool is slugs; resolve titles, drop self + already-linked, filter by q diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 4293a4aa..452d3f11 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -457,6 +457,30 @@ (host-bl-test "type-valid? is vacuously true with no schemas (gradual)" (host/blog-type-valid? "ppost" "(p \"anything\")") true) +;; -- relations-as-posts: declaration-driven candidate pools (plans/relations-as-posts.md) -- +;; The picker's candidate set is the down-closure of a relation's anchors. is-a/subtype-of +;; are anchored by `type`, so they offer the WHOLE type closure — the roots (type/tag/ +;; article) AND the instances — fixing the wrinkle where only instances showed. +(host-bl-test "is-a candidates = the type closure: roots (type/tag/article) AND instances" + (let ((pool (host/blog--candidate-pool "is-a"))) + (list (contains? pool "type") (contains? pool "tag") + (contains? pool "article") (contains? pool "ocaml"))) ;; ocaml is-a tag + (list true true true true)) +(host-bl-test "is-a candidates exclude a plain content post (not is-a/subtype-reachable to Type)" + (contains? (host/blog--candidate-pool "is-a") "ppost") false) +(host-bl-test "tagged candidates are anchored by tag (tag + its instances)" + (let ((pool (host/blog--candidate-pool "tagged"))) + (list (contains? pool "tag") (contains? pool "ocaml"))) + (list true true)) +(host-bl-test "related candidates = every post (no declaration anchors it)" + (let ((pool (host/blog--candidate-pool "related"))) + (list (contains? pool "type") (contains? pool "ppost"))) + (list true true)) +;; and it flows through to the live picker endpoint: the is-a picker now offers a type root +(host-bl-test "is-a relate-options offers the type roots (Article)" + (contains? (dream-resp-body (host-bl-app (host-bl-req "/ppost/relate-options?kind=is-a"))) "Article") + true) + ;; -- Phase 3: tags as posts -- (ocaml is-a tag, from the seed-types test above) (host-bl-test "is-tag?: a post that is-a tag is a tag; others are not" (list (host/blog-is-tag? "ocaml") (host/blog-is-tag? "ppost")) diff --git a/plans/relations-as-posts.md b/plans/relations-as-posts.md new file mode 100644 index 00000000..b8f59f57 --- /dev/null +++ b/plans/relations-as-posts.md @@ -0,0 +1,80 @@ +# 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 +- Seed `is-a`/`subtype-of`/`tagged`/`related` as posts that own their metadata + (`:symmetric`, `:label`, `:inverse-label`, **cardinality**, **end roles**). The registry + `host/blog-rel-kinds` melts into reads off these posts. A relation can declare its + *subject*-end anchor too (who may be the source), not just object. + +### Slice 3 — typed relations (target-type constraints) +- A declaration carries a **target-type constraint**: the *other* end must be (an instance + of) some type. `is-a`'s object must be a type; a hypothetical `wrote`'s object must be a + `Work`. Validation on relate (and on save) = `is-a?` against the constraint. This is the + jump from "candidate set" to a real relation schema. Picker candidates and validation + read the *same* constraint. + +### Slice 4 — type algebra +Types are posts + `subtype-of` is a partial order ⇒ a **lattice**, and `is-a?` is transitive +set-membership ⇒ extents have set semantics. So algebra is expressible as posts: +- **Intersection** `A ∧ B` — a type-post whose membership predicate is `is-a? A ∧ is-a? B` + (meet / GLB in the lattice). **Union** `A ∨ B` — `is-a? A ∨ is-a? B` (join / LUB). +- **Refinement** `{x : T | φ(x)}` — a type-post with a `:constraint` predicate over a post + (generalises today's `article` schema "must have a heading"). Gradual: declaring the type + adds the obligation; the next save must satisfy it. +- Algebraic types are *themselves posts* with edges to their operands — `is-a?` recurses on + the expression. Meta-circular: the algebra lives in the graph it describes. + +### Slice 5 — constraints as posts + validation +- Promote the schema/`:constraint` slot to **constraint-posts** (a predicate expr + + message), attachable to any type. Save-time validation evaluates the constraints of a + post's full (transitive) type set. Relation cardinality (`is-a` single-valued? `tagged` + many?) becomes a declared constraint too. + +## 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-circularity** — `is-a` needs `is-a`; seed relation-posts + `Type is-a + Type`(?) idempotently, as the type seed already is. +4. **Cost** — `reach-down` is a BFS of direct-edge scans; fine for a small blog, revisit with + a `lib/relations` transitive query if the graph grows.