host: relations-as-posts slice 1 — declaration-driven candidate pools
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
80
plans/relations-as-posts.md
Normal file
80
plans/relations-as-posts.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user